积分模块上线第二周,客服转来一条:有个会员买了 1 单,积分流水里躺着 3 条一模一样的加分记录。
先排除并发
第一反应是并发扣减那类问题,查了日志,三条请求时间差了 4 秒和 7 秒,不是同时来的。再看 user-agent 和请求路径,是同一个浏览器在订单结果页连着刷新了两次。
原因是加分接口写成了「收到请求就 insert 一条流水」,没有任何去重。用户点慢一点、网络转个圈,多点两下,分就多加了。
幂等键怎么定
我们没用请求里带的随机数,那种防不住刷新。用的是业务本身能确定的东西:order_id 加 change_type。一个订单的「下单加分」只可能有一条,「退货扣分」是另一个 type,各自独立。
数据库里给积分流水表加了唯一索引 (order_id, change_type),重复插入直接报冲突,后端捕获后不报错,把第一次那条记录查出来原样返回。前端拿到的还是成功,用户看不出区别。
这一步最关键的是「重复请求返回原结果,而不是返回错误」。返回错误,用户会以为没加成,再点一次。
前端那层
前端提交后按钮置灰,结果页跳走时把地址换成另一个,避免刷新重新 POST。这层只是让人少点两下,不当真。真正兜底的还是数据库那唯一索引。
另外在 Redis 存了一个请求令牌,10 分钟过期,同一个令牌重复进来直接短路。这层是为了拦那些连唯一索引都走不到的场景,比如发短信、调外部接口,成本比一次数据库写入低。
修数据
已经多出来的那 2 条不能直接删,得留痕。我们加了一张 adjustment 记录,按订单号把 3 条合成 1 条,多出的写一条负数冲掉,备注写清是重复提交修正。一共核出 11 个订单受影响,跑了半小时脚本,导出一张明细给客服留底。
后来把「所有会改余额的接口」列了个单子,一共 6 个,逐个补了幂等键。有两个当时没出过问题,也一起补了。
那次之后我们有个习惯:凡是带「加」「减」「扣」「发」字眼的接口,评审时先问一句,重复调用会怎样。

