幂等的两块地基:怎么"记好账",怎么"锁住门"
通用工程思想 · 编程博客 · 第 6 篇 主题:凡是"做一次就不可逆"的操作(发消息、下单、扣款、派任务),都怕同一件事被做两遍。怎么从根本上防住?靠两样东西:一块记"做过什么"的账本,和一把保证"一次只有一个人在干"的锁。这篇讲它们分别怎么设计。
开场一个比喻
想象一间仓库,管理员最怕一件事:同一件活被派出去两遍——搬货的人跑了两趟,白费力气甚至出错。
他防这个靠两样东西:
- 一块本子:每派一次,记一笔"这个活了"——下次一看本子,就知道不该再派;
- 一把锁:验收/搬运时,一次只让一个工头进来,别的在门口排队——同时只有一个人在动。
"一次只做一遍"这件事,用工程话说叫幂等(idempotency)。搞懂它,是写这类系统的基本功。今天拆成两块讲:怎么记账、怎么上锁。
先讲:为什么"防重复"这么难?
防重复看着简单,真正难在两种情况:
- 做了,但结果没确认到:你可能已经把消息发出去了/下单成功了,只是那一刻程序没"看到"确认,于是怀疑"到底做了没"。该不该重做一次?
- 两件事同时在做:定时任务和手动操作同时触发,同一批对象被两处各发一次。
真正稳的防重复,不是靠"小心翼翼别出错",而是设计成"哪怕出错,也不会重复"。下面两块地基地就是这个意思。
地基一:记账本(怎么"记好做过什么")
核心思路:做之前,先记一笔;做完,改一笔。
具体是"两段式":
准备做"这件事"时:
1. 先查账:这件事做过/没做过?
做过 → 直接跳过,别再做
2. 没做过 → 先记一笔"占用中/未知"(占位)
3. 真正去做
4. 做完确认成功 → 把这一笔改成"已成功"
关键点睛:为什么"做之前就先占位"?
因为你拦不住"做到一半崩溃"。如果不占位就去做,刚做完、正要记成功时崩溃了——那这一笔没记上,下次会以为"没做过",又做一遍 → 重复!
而先占位的话,哪怕中途崩溃、结果不明,账上"这一笔"已经在——下次一看,当"已经处理过",跳过 → 不会重做。
一句话:"先占位"让崩溃也翻不了天——要么完成,要么留个'未知'占位,总之不会变成'没做过'。 这就是"宁可当已发生,也不冒重做的险"。
记账还有个"硬伤要治":账本别写烂
账本是反复改写的文件。最怕:写一半崩溃,把账本写成"半截烂文件"——那就连"查账"都查不了。
治法的核心不是"小心写",而是"原子写":
写账本时:
1. 先写一个"临时文件"
2. 再把临时文件"整体替换"掉原账本
└─ 好处:要坏也最多坏最近一次替换,绝不会留下写一半的烂账本
再加一道保险(fail-closed / 宁停勿错):万一账本还是坏了,就直接停,别在"账不可信"的情况下继续做高危操作——因为账一旦不实,防重复就失灵了,继续下去比停更危险。
记账的"钥匙"长什么样
让每条"活"能被唯一识别,得给它一把唯一钥匙,一般由几段拼出来,能让你唯一认出"是这回事":
常见拼法:
什么任务 : 哪天 : 谁/对谁 : 做的具体那件事的代号
有了唯一钥匙,"查有没有做过"就是"账本里有没有这把钥匙"。钥匙设计得够唯一,判重才准——这是记账的关键细节(拼少了会误判"重"或"漏")。
地基二:单实例锁(怎么保证"一次只有一个人在干")
记账挡住"重复做一遍";锁挡住"同时两个人做"。原理极简:
开工前:
试着在磁盘上"新建"一把锁文件
新建成功 → 没人抢,我拿到权,继续
新建失败(文件已在) → 已有一个在跑,我退出
干完 → 删掉锁文件,把权还回去
它的优点:是最轻、零依赖、几乎每个语言都支持的自带方式——不必引入什么重型中间件,一个"独占创建文件"的原子操作就能实现互斥。
一个绕不开的小坑:陈旧锁。 如果进程被强杀,锁文件会残留在磁盘上——之后系统一直误以为"有人在跑",不让新进程干。
破法:报错信息里明确提示"如果确认没有进程在跑,请手动删除这个锁文件",把自救方法告诉用户。更高阶的方案(如"锁里记 PID + 超时、自动检测僵死进程")更智能,但复杂度更高,要按需取舍。
两块地基怎么配合
把两块合起来,防重就立体了:
第 1 道:记账(往前的防)
做之前查"做过没",做过就跳过 —— 防止"这次"把"上次"再做一遍
第 2 道:锁(往后的防)
开工前抢锁,抢不到就退出 —— 防止"两个同时"各做一遍
有些平台还自带更上层的并发控制(比如 CI 的"并发组"),可以当第三道再兜底一层。守得越多,越稳。
用的时候注意(边界 / 取舍)
- 先占位 ≠ 真的做:占位就先留"未知",是防御性设计——代价是"结果未知"会暂时占着名额、以后得人工补看。要能接受这个折中。
- 原子写 ≠ 彻底不坏:它只是让"写一半崩溃"不产生烂文件,防止的是"写入中断"这一坏;不是解决所有存储问题。别指望它包治百病。
- 陈旧锁方案有代价:提示"手动删"最简单但靠人;自动检测僵死更聪明但要对付"误判在跑的进程"的风险。按"你的崩溃频率、影响大小"取平衡,别一上来就堆复杂方案。
- 钥匙要够唯一:是你写这套时最容易大意的地方——拼少了,真重复会漏判。
落地检查清单
- 高危操作是否用"先占位→做事→确认成功"的两段式?
- 占位是否保证"哪怕中途崩溃,下次也会当已处理跳过"?
- 每件事的"唯一钥匙"是否足够唯一、不会漏判或误判?
- 常改写的账本/记录文件,是否用"临时文件+原子替换"来写?
- 账本损坏时,是否选择"fail-closed(停),而非在账不可信下继续"?
- 需要互斥的场景,是否有"单实例锁",防止两个进程同时跑?
- 陈旧锁是否至少给出了"确认无进程后手动删除"的自救提示?
一句话带走
"防重复这件事,不能靠'小心',要靠设计:一块先占位、原子写的账本防'再做一遍',一把独占创建的锁防'同时两个人在干'——外加账不可信就宁停勿错。把账记明白、把门锁好,重复根本进不来。"