幂等性在分布式系统中的实战应用与常见陷阱分析

UX-世杰 安全 阅读 1,923
赞 24 收藏
二维码
手机扫码查看
反馈

谁更灵活?谁更省事?

幂等性这玩意儿,我最早是在做支付回调时被逼着搞明白的。客户点两次“确认付款”,后端不能扣两笔钱;用户手抖连点三次提交订单,不能生成三单。那会儿我还在用“前端加按钮禁用 + 后端查单据是否存在”这种土办法,结果上线三天,运营反馈“有1%的订单重复创建”,我连夜翻日志,发现是网络抖动导致前端没收到响应,用户以为失败又重点了——按钮禁用早被绕过去了。

幂等性在分布式系统中的实战应用与常见陷阱分析

后来我开始系统梳理幂等方案,试过数据库唯一索引、Redis token、分布式锁、请求指纹、业务状态机……踩过的坑比写的代码还多。今天不讲理论,就聊我日常真正在用、敢上线、改起来不抓狂的几个方案,按我自己的使用频率排个序:Redis token > 数据库唯一索引 > 请求指纹(带签名)> 分布式锁(慎用)。

Redis token:我目前的主力方案

简单说,就是“客户端先领票,再凭票办事”。用户点击提交,前端先发个 /api/idempotent/token 拿一个临时 token(比如 UUID),然后把这个 token 塞进后续请求头或 body 里,后端用它在 Redis 里做原子 set-if-not-exists。

核心逻辑就这几行:

// 后端(Node.js + Redis)
app.post('/api/order', async (req, res) => {
  const { idempotentToken } = req.headers;
  if (!idempotentToken) {
    return res.status(400).json({ error: 'Missing idempotentToken' });
  }

  // 尝试设置 token,过期时间设为业务合理窗口(比如 10 分钟)
  const isSet = await redis.set(idempotentToken, 'processing', 'NX', 'EX', 600);
  if (!isSet) {
    // 已存在,说明是重放请求
    const result = await getOrderResultByToken(idempotentToken);
    return res.json(result);
  }

  try {
    const order = await createOrder(req.body);
    // 成功后存结果,供重试查询
    await redis.setex(${idempotentToken}:result, 3600, JSON.stringify(order));
    res.json(order);
  } catch (err) {
    // 失败也要清理 token?看业务。我一般不清,让下次重试走兜底逻辑
    await redis.del(idempotentToken);
    throw err;
  }
});

我比较喜欢用这个,因为够轻、够快、够可控。Redis 的 SET NX EX 是原子的,不用自己写 Lua 脚本防竞态;token 过期时间能按业务定(下单 10 分钟,退款 24 小时);而且失败后还能查结果,对用户友好——“您刚才的订单已提交,正在处理中”。唯一要注意的是:token 必须由前端生成并透传,不能后端生成再塞给前端再回传,否则中间网络断了就断了,没法重试。我一般让前端用 crypto.randomUUID() 或 Date.now() + Math.random() 拼一个,够用就行。

数据库唯一索引:最糙但最稳

这个我早期项目全靠它活下来。原理粗暴:给订单表加个 idempotent_key 字段,加上唯一索引,每次插入前把业务标识(比如用户ID+商品ID+时间戳哈希)塞进去。冲突就报错,捕获后查库返回已有记录。

ALTER TABLE orders ADD COLUMN idempotent_key VARCHAR(64) NOT NULL DEFAULT '';
ALTER TABLE orders ADD UNIQUE INDEX uk_idempotent_key (idempotent_key);
// 插入时
const key = md5(${userId}_${productId}_${Math.floor(Date.now() / 60000)});
try {
  await db.insert('orders', { ...data, idempotent_key: key });
} catch (err) {
  if (err.code === 'ER_DUP_ENTRY') {
    const order = await db.selectOne('orders', { idempotent_key: key });
    return res.json(order);
  }
  throw err;
}

优点是啥都不依赖,MySQL 自带保障,排查问题也直白——直接查索引冲突日志。缺点也很明显:**插入失败再查一次,性能差一截;而且一旦表大了,唯一索引本身会影响写入速度**。我去年在一个日增 50w 订单的系统里把它换掉了,因为 DBA 报警说唯一索引导致批量导入延迟飙升。不过现在小项目、管理后台、内部工具,我还是首选它——改两行 SQL + 几行代码,十分钟搞定,不折腾。

请求指纹(带签名):看着高级,实际鸡肋

原理是把整个请求体 + 时间戳 + 密钥做签名,生成 fingerprint,存在 Redis 或 DB。听起来很安全,但实际用起来全是坑:

  • 前端 JSON 序列化顺序不一致(key 排序不同导致签名不同)
  • 时间戳精度问题(前后端时钟差几秒就失效)
  • 文件上传、base64 图片等大字段导致签名计算慢、存储膨胀
  • 调试困难:你永远不知道是签名错了,还是时间戳超了,还是密钥没同步

我试过一次,折腾半天发现 axios 默认把空对象序列化成 {},而 fetch 有时是 {} 有时是 {}(别笑,真有这事儿),最后放弃。除非你团队有专职安全工程师盯这套,否则纯属给自己加戏。

分布式锁:别碰,真的

用 Redlock 或 ZooKeeper 锁住 “用户ID:操作类型” 这种 key,听着很正统。但我线上只用过一次,结果因为 Redis 主从切换,锁提前释放,两个请求同时进入临界区,订单重复了。后来查文档才发现 Redlock 在网络分区下不满足安全性要求。现在除非是金融级强一致性场景(而且得配专业中间件团队),否则我一律回避。锁不是万能的,它是把双刃剑,而且刃特别快。

我的选型逻辑

看场景,我一般选:

  • 对外 API(尤其是支付、下单):Redis token。它平衡了性能、可控性和可观测性,出问题能快速定位是 token 重复还是业务异常。
  • 内部系统、低频操作、无外部依赖的项目:数据库唯一索引。省事,不引入新组件,DBA 也熟悉。
  • 需要严格防重放且有统一网关的架构:在网关层做 token 预检(比如 Kong + Redis),后端无感。我们有个微服务集群就是这么干的,网关统一发 token、校验、缓存结果,业务服务彻底不用关心幂等。

至于 JWT、OAuth scope、自定义 header 等“看起来很规范”的方案?我基本不用。因为它们解决的是认证授权,不是幂等性。硬套反而增加链路复杂度,还容易漏掉边界 case(比如 token 过期后重试,到底是拒绝还是继续处理?)。

最后说一句实在的:没有银弹。我上个月刚在一个老系统里加 Redis token,结果发现下游第三方接口本身不幂等,只能在它前面再包一层 mock 缓存——所以幂等性从来不是单点问题,而是整条链路的事。别光盯着自己写的那一段。

以上是我的对比总结,有不同看法欢迎评论区交流。如果你也踩过“点了三次,生成了三张优惠券”这种坑,欢迎来吐槽。这个技巧的拓展用法还有很多,比如如何用 Redis Stream 做幂等 + 异步结果推送,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论