用户手抖点两下,公司损失一万八:中小团队的幂等性实战

记得是三年前的一个周五晚上,那时候我刚加入一家电商创业团队做架构重构。晚上10点,客服群突然炸了:“用户说充值了一笔钱,但是余额里多了两倍的数额!”

排查下来,原因简单得让人想哭:用户在弱网环境下点击“确认支付”,前端Loading转圈没反应,用户心急多点了几下。请求因为网络抖动几乎同时到达后端,不仅扣款流水记了两笔,用户余额也加了两次。

那天晚上我们紧急回滚数据、安抚用户,折腾到凌晨三点。

很多资深开发在面试时能把“幂等性”背得滚瓜烂熟,但真到了中小项目的实战中,往往因为赶进度、觉得“概率小”或者过度依赖前端限制,而埋下巨大的隐患。

对于我们这种资源有限、没有庞大风控系统的中小团队来说,如何用最低的成本构建一套防重复提交的防线?结合我这几年的踩坑经验,聊聊三个最实用的设计策略。

策略一:数据库唯一索引 —— 最后的兜底防线

这是最粗暴但也最有效的方法,特别适合**新增类(Insert)**的操作。

刚接手那个出事故的项目时,我发现很多核心表居然没有唯一约束,完全依赖代码逻辑去查重。代码逻辑是脆弱的,特别是在并发场景下,“先查后写”的各种检查就是个摆设。

真实案例: 我们的优惠券领取接口。起初,开发小哥写了段逻辑:先查用户是否领过该券,没领过则插入领取记录。结果大促由于并发高,同一个用户在毫秒级内发了三个请求,三个线程同时读到“未领取”,然后哐哐哐插入了三条记录。

解决方案: 我们立刻调整了数据库Schema,利用数据库的强一致性机制。

建立联合唯一索引(Unique Index)。比如在优惠券领取记录表中,将 user_idcoupon_id 设为联合唯一索引。

ALTER TABLE user_coupon
ADD CONSTRAINT uk_user_coupon UNIQUE (user_id, coupon_id);

配图

落地效果: 当重复请求打进来时,第一个请求成功插入,后续请求会直接报 DuplicateKeyException。我们在Service层捕获这个异常,直接返回“领取成功”或者“您已领取”给前端。

踩坑提醒: 很多同学觉得报异常不好看。其实对于幂等性设计,吞掉报错并返回成功结果是常见做法。因为对用户来说,他的意图是“拥有这张券”,既然数据库里已经有了,告诉他“成功”在业务语义上是完全正确的。

策略二:Redis Token机制 —— 拦截90%的“手抖”

数据库约束虽然稳,但它是基于磁盘IO的,如果前端因为Bug搞了个死循环请求,数据库很容易被打挂。所以,我们需要在请求到达数据库之前,在内存层挡一道。

这也就是经典的“一锁二判三更新”中的“锁”。

真实场景: 我们的后台管理系统,运营人员上传Excel批量发货。文件比较大,解析要5秒。运营人员以为没点上,疯狂点击“上传”。结果服务器开启了8个线程同时解析同一个Excel,CPU瞬间飙到100%,整个后台瘫痪。

解决方案: 我们引入了基于Redis的Token(或者叫防重Key)机制。对于非C端的复杂操作,我推荐**“参数指纹锁”**。

不需要客户端先申请Token(那样交互太复杂,前端兄弟会骂人),而是由服务端根据请求参数生成Key。

  1. 生成Key:用户ID + 接口名 + 关键参数(MD5) 拼接成一个字符串。
  2. 原子操作: 使用Redis的 setNx (Set if Not eXists)。
// 伪代码示例:使用Redis实现分布式锁
String lockKey = "lock:upload:" + userId + ":" + fileMd5;
// 设置30秒过期,防止死锁
boolean isLocked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);

if (!isLocked) {
    throw new BusinessException("系统正在处理中,请勿重复操作");
}

try {
    // 执行耗时业务逻辑
    processExcelFile(file);
} finally {
    // 任务完成后释放锁
    // 注意:这里是否释放锁取决于业务。如果是防刷,可以不释放,等它自动过期
    redisTemplate.delete(lockKey);
}

落地细节: 这个方法我用了两年多,非常稳定。重点在于Key的过期时间设计

  • 如果是防手抖(如支付):过期时间设置短一点(如5秒),业务执行完必须释放锁。
  • 如果是防刷单:过期时间设置长一点(如1分钟),业务执行完释放锁,强行让用户冷静一下。

策略三:状态机幂等 —— 业务逻辑的“免疫系统”

前两种是技术手段,而状态机是业务层面的终极保障,特别是针对**更新类(Update)**操作。

真实案例: 在订单系统中,订单状态流转是:待支付 -> 已支付 -> 待发货。 曾出现过一个诡异Bug:支付回调接口被第三方支付公司重复调用了(这是常态,人家要确保你收到了)。第一次调用将订单改为“已支付”,并触发了发货逻辑;第二次调用又触发了一次发货逻辑。结果仓库发了两份货。

配图

解决方案: 利用SQL的行级锁和状态条件更新,实现天然幂等。

不要写这样的代码:

-- 危险代码
UPDATE order SET status = 'PAID' WHERE order_id = 123;

要写这样的代码:

-- 幂等代码
UPDATE order SET status = 'PAID' 
WHERE order_id = 123 AND status = 'PENDING'; -- 核心在这里

落地效果: 通过 affected_rows(受影响行数)来判断。

  • 如果是1:说明更新成功,执行后续发货逻辑。
  • 如果是0:说明订单已经不是 PENDING 状态(可能是已经支付了),直接返回“处理成功”给回调方,停止后续所有业务逻辑。

这种方式利用了数据库的当前读(Current Read)特性,即使是高并发场景,数据库也会帮我们将请求排队,保证只有一个请求能修改状态成功。

思考与行动

写到这里,我想问大家一个问题:你现在的项目中,有哪些涉及资金或核心资源变动的接口,完全依赖前端按钮置灰来防止重复提交?

如果答案是肯定的,这大概率是一个待引爆的地雷。

幂等性设计不是要为了炫技去引入分布式事务框架(如Seata对于小团队太重了),而是用最简单的手段解决80%的问题。

给中小团队架构师的3个落地建议:

  1. 盘点高危接口: 这周花1小时,拉出支付、退款、发券、Excel导入这几类接口,检查是否有数据库唯一索引兜底。
  2. 封装通用注解: 写一个 @Idempotent 注解,结合Redis,切面处理重复请求。让开发同学只需要加一个注解就能防手抖,降低推广难度。
  3. 统一响应规范: 与前端约定好,当触发幂等拦截时,是报错提示“请勿重复提交”,还是静默返回“操作成功”。对于用户体验来说,查询类的幂等通常静默处理,修改类的建议给与适当提示。

架构设计的本质不是追求完美,而是管理风险。希望这些实战经验能帮你守住系统的“防洪堤”。