幂等性实战:同一个请求Key带着不同参数来了怎么办?


别让你的重试接口悄悄把钱付两遍!开发保证不重复的接口,难点不在第一次请求成功时,而在第二次请求内容不同、系统崩溃或超时的时候。本文用大白话讲透如何用数据库行锁、命令哈希和状态机,真正搞定支付级的不重复执行。

原文标题: Idempotency Is Easy Until the Second Request Is Different
作者背景: Dochia 团队,专注于构建高韧性命令行工具和分布式系统。

搞不定第二次请求,你的幂等就是个缓存玩具

很多人觉得幂等很简单。不就是客户端发个请求带个Key,服务端记一下,重复请求来了就把第一次的结果再返回去嘛。这个做法,应付演示还行,真上线早晚得出事。

为啥?因为第二次来的请求,可能内容和第一次不一样,可能第一次还没处理完,也可能第一次处理到一半服务器炸了。真

正的幂等,不是记住“这个Key我见过”,而是记住“这个Key当时代表什么意思”。做不到这点,你的接口就是个高级重复播放器,碰上乱发Key的客户端或者网络超时,钱付两遍、邮件发两封这种事,早晚得找你喝茶。

一个请求带着专属编号来了

你写了一个收钱接口。客户端要发钱,它得先给你一个请求。这个请求里除了告诉你要给谁转多少钱,还得带上一个独一无二的编号。这个编号就是幂等Key。你的服务器拿到这个Key,第一件事就是去数据库翻翻:这个Key我之前见过没?没见过,好,那这就是第一次,赶紧处理,把钱收了,然后把处理结果存下来。这第一步,谁都会做,没啥难度。关键在后面。

同样的编号又来了一遍但内容换了

这才是噩梦的开始。客户端又发来一个请求,带着同样的编号。但是,你仔细一看,这次要转的金额从十块变成了一百块。这下麻烦了。

最蠢的做法是,你一看编号见过,二话不说把第一次成功的那个十块钱的结果返回去。客户端收到结果,一看成功了,它以为那一百块转完了,实际上只转了十块。这锅谁来背?肯定是你的接口。

正确的做法是,你必须把第二次来的请求内容和第一次存的做个对比。不一样?那就甩给对方一个错误,告诉他“你用同一个编号干了不同的事,这不行”,错误码就用 409 冲突。这就好比有人用了你家门禁卡开了门,第二次他用同一张卡想撬锁,系统得报警,不能直接把他放进屋。

拿下数据库的第一行记录才算抢到干活资格

想象一下,客户端急眼了,一口气发了两个一模一样的请求。两个请求差不多同时到你的服务器,而且你的服务器还不止一台。两台机器同时去数据库查:“这个编号见过没?” 巧了,都还没见过。于是两台机器都开始执行转账。完蛋,钱付了两遍。

破局的关键在于数据库的一个原子操作。你不能先查再插,要直接试着插入一条状态为“处理中”的记录。数据库得保证,对于这个唯一的编号,只有第一个插入操作能成功。谁插入成功了,谁才有资格接着干活。另一个请求插入失败了,它就知道自己来晚了,老老实实去查状态,等着把结果拿回去给客户端。这一招叫“先插队再干活”,比“先看看有没有人再排队”要靠谱一万倍。

把请求内容做成指纹防止偷梁换柱

光靠Key还不够,你得把请求的“核心意思”做成一个指纹。注意,不是直接把整个JSON字符串拿去做哈希。为啥?因为JSON里的字段顺序可能变,空格可能不一样,甚至还可能有接口自动补上的默认值。

你得先把请求里真正影响业务的关键字段抽出来:操作是“创建支付”,账号是哪个,金额是多少,币种是欧元还是美元。把这些关键信息按一个固定的顺序排好,再算出一个哈希值。这个哈希值就是请求的指纹。第一次处理完,把指纹和Key一起存起来。第二次请求来了,就算Key一样,指纹对不上,立马报错。这就好比你可以用不同的快递盒子寄同一本书,但盒子里书的ISBN号是不变的,对不上号就拒收。

处理中是个公开状态别藏着掖着

第一个请求抢到了干活资格,开始处理。这时候第二个请求又来了。它发现这个Key已经被占了,状态是“处理中”。这时候你不能让客户端干等着或者一直报错。

你得有个说法。你可以返回 202 状态码,告诉客户端“正在处理,你过会儿再来问”。或者返回 409 加一个 重试间隔 的头,告诉它“忙着呢,等几秒再试”。最离谱的做法是返回 500 让客户端自己瞎猜。你得让客户端知道现在啥情况,这是API的基本礼貌。你内部的状态机转了,也得让外面的人知道大概的进度。

供应商超时那一刻你的保证就靠不住了

真正的麻烦在这儿。你的事务把钱打出去了,你调用了支付宝的接口。支付宝那边显示扣款成功,但就在它刚给你返回“成功”俩字的时候,你的服务器断电了。你的数据库里还没来得及记下“成功”这个结果。客户端重试,带着同样的Key又来了。

现在你数据库里这笔交易的状态还是“处理中”。而外面,钱已经转完了。你怎么办?你再执行一遍转账?那客户就被扣两遍钱,绝对不行。你不执行,又不知道这笔钱到底成功没。

这里唯一的出路是,你在调用支付宝的时候,必须带上一个你这边能控制的不重复的交易号。这样,你重新启动或者重试的时候,不是去执行第二次转账,而是拿着这个交易号去支付宝那边问:“交易号是XXX的这笔单子,到底成没成功?” 这叫“查询状态”,而不是“重复操作”。你的系统得有这个恢复的能力,不能光会干活,不会善后。

重放结果是一种合同不是偷懒借口

假设第一次顺利搞定了,钱也转了,结果也存了。客户端过了十分钟又拿同一个Key来问。你从数据库里把存的结果拿出来,打回去。这里有个坑:你存的要是当时的结果,还是现在的最新状态?

第一次的结果可能是“处理中”。十分钟后,银行清算完了,这笔钱早到账了。你要重放,是把“处理中”这个老结果还给客户端,还是去查一下这笔支付的最新状态,把“已结算”还回去?

这没有标准答案,但你的API文档里必须写清楚。如果你承诺“重放原响应”,那你就得老老实实把第一次的响应体存起来。如果你说“返回资源当前状态”,那你每次都得去查一遍。这两种合同不一样,不能混着用。最怕的是你上次存的是V1格式的响应,下次重放的时候代码升级到V2了,你返回一个牛头不对马嘴的数据结构让客户端解析失败。

你的消息队列也在犯同样的毛病

别以为搞定HTTP请求就万事大吉了。你的系统里肯定有消息队列。支付成功之后,你得发个“支付成功”的消息,下游要去记账、发邮件。这个消费端,同样有不重复的问题。

消息队列给你的承诺通常是“至少一次”,意思是同一条消息可能被推送两次。如果你的消费者收到消息就直接发邮件,那用户可能收到两封一样的邮件。

解决办法还是那一套。在处理消息之前,你先在一个“收件箱”表里试着插入一条记录,记录的唯一键就是消息ID或者业务单号。插入成功了,再干活。干完活,再标记完成。如果第二次收到同一条消息,插入记录的时候会因为唯一键冲突而失败,你就知道这活儿已经干过了,直接跳过。这一步是防止消费端重复的护身符。

过期是合同的一部分该忘就得忘

你不能把所有的Key和结果都存一辈子。总要有个过期时间。比如你规定幂等Key的有效期是24小时。24小时之后,客户端再拿这个Key来,你得把它当作一个全新的请求。

这个事你得想清楚。如果你的客户端因为某些原因,比如网络重试策略有问题,25小时后才重试,你把它当新请求处理了,那可能会创建第二笔支付。如果这是你想要的,文档里得写明白。如果你不希望这样,那你就得把有效期定得长一点,或者拒绝过期的重试。

更重要的是,对于状态是“处理中”但已经过期的记录,你不能简单删掉。删掉了,万一那笔支付其实成功了,只是你忘了记,那就给了重复执行的机会。你得有个专门的恢复机制来处理这些僵尸记录,把它们的状态最终确定下来。

失败到底能不能重试得有个明确说法

不是所有失败都应该无脑重试。

参数少了个必填字段,你再发一遍还是少,这种失败直接报错就行,没必要存到幂等表里。但是,如果是因为余额不足导致的失败,而且余额是随时会变的,你怎么办?是把这个失败结果存下来,以后每次重试都返回“余额不足”?还是让客户端换个新的Key再试一次?

更复杂的情况是,你扣库存成功了,但返回响应的时候网络断了。客户端重试,你不能告诉他“失败了,再试一次”,那会重复扣库存。你也不能直接告诉他“成功了”,因为你不知道他那边有没有超时。

这时候你的状态机里需要有一个“状态未知,需要恢复”的状态。重试的请求进来,看到这个状态,不能直接干业务,也不能直接返回成功,而是要去触发一个恢复流程,查清楚到底扣没扣成,拿到确切结果后再返回。

别造你不需要的通用轮子

搞这么一套东西,代码量不小,数据库设计也麻烦。但对于那种“把用户名从A改成B”的操作,有必要吗?大概率没有。因为改用户名这个操作本身就是幂等的,你改一百遍,最后用户名还是B。而且改错了的代价也不大。

真正需要这套机制的,是那些付钱、发短信、扣库存、发独家优惠券这种干了第二次会出大事的场景。如果你的业务场景里,重复执行的后果只是多打一条没啥用的日志,那就别费这个劲了。加个数据库唯一约束可能就够用了。判断要不要上重量级方案,就看两个点:重复执行的危害有多大,以及你能不能轻松发现并回滚这个重复操作。

上线前必须做好的几个实战检查

别光写单元测试骗自己。把服务器搞乱,模拟几个真正的故障场景,看看你的系统扛不扛得住。

第一,并发测试。同时发两个一模一样的请求过来,看是不是只有一个请求真正被执行了,另一个有没有拿到正确的结果。

第二,参数篡改测试。同一个Key,先发转十块,再发转一百块,看系统是不是果断报错,而不是悄悄处理。

第三,下游超时崩溃测试。调用支付接口的时候,让支付接口成功,但你的服务在收到回复前崩溃。重启之后,用同一个Key重试,看系统是会再次调用支付接口(错误),还是会先去查一下那笔订单的状态(正确)。

第四,消息重复推送测试。让队列把同一条“支付成功”的消息推送两次,看你的消费者是不是只记了一次账、只发了一封邮件。

第五,过期重放测试。等你的幂等记录过期后,再用同一个Key请求,看系统行为是否符合你文档里的描述。

只有这几关都过了,你的系统才敢说真正理解并实现了幂等,而不是只会复读机的玩意儿。

第二次请求在没有被证明是重放之前什么都不是

别把问题想简单了。幂等不是查查表里有没有这个Key。幂等是你要记住这个Key当时代表着什么样的操作,那个操作执行到了哪一步,以及最终的结果到底是什么。

第二次来的请求,它可能是真诚的重试,也可能是披着羊皮的狼,带着完全不同的参数来搞事情。它可能在你干活干到一半的时候冲进来,也可能在你刚把事情干完但还没来得及宣布结果的时候冲进来。

你的系统,得有能力证明这第二次请求到底属于哪种情况。证明不了,那就得进入恢复流程,或者直接拒绝。记住,幂等Key本身不是保证。你能精确记住第一次到底发生了什么,并且针对不同情况做出正确反应,那才是真正的保证。别让你的第二次请求,变成一个破坏规则的定时炸弹。