7AA7 Blog

幂等设计的工程实践:重复请求防护体系

顾清池 UID:117
2026-09-19 21:12
54 0 1

用户点了一次”支付”,结果扣了两笔钱。这类事故几乎总能追溯到同一个原因:某个环节没做幂等。另一面是,团队知道要做幂等,于是给每个接口都加了一张去重表,最后系统又慢又复杂,边角场景上依然会漏。

幂等不是”加个锁”或”加张表”,而是一套需要分层的防护体系。

幂等的严格定义

数学上的定义:对同一个操作的多次执行与一次执行产生的效果相同。

注意定义里说的是”效果”,不是”返回值”。这一点常被误读,导致一些不必要的复杂设计。比如下单接口,第一次请求返回:

{ "code": 0, "data": { "order_id": "SO202610210001", "status": "created" } }

重复请求返回:

{ "code": 0, "data": { "order_id": "SO202610210001", "status": "created" } }

两次返回一致当然最好,但幂等并不强制要求这一点。你可以让重复请求返回 { "code": 4090, "message": "重复请求" },只要”订单只创建了一次”这个副作用成立,它就是幂等的。反过来说,如果两次都返回成功却生成了两个订单,那么无论返回值多一致,它都不是幂等的。

幂等的作用域也要写清楚。 “同一笔订单只支付一次”是业务级幂等,”同一个幂等键只处理一次”是传输级幂等,多数事故出在把后者当成了前者。

重复请求从哪里来

防护体系要先搞清楚敌人是谁。

来源 典型场景 特征 是否可避免
客户端重试 超时后重发、SDK 自动重试 请求内容完全相同 否,超时不可判定
网络中间件重传 网关重试、负载均衡重试、TCP 重传 服务端可能收到两次完整请求 否
消息队列重复投递 消费者处理成功但 ACK 丢失 同一条消息被多次消费 否,至少一次语义决定
用户重复点击 双击按钮、刷新提交页、回退再提交 内容相同但携带不同的请求 ID 部分可避免

第三条最容易被忽略。主流消息队列普遍提供”至少一次”投递语义:消费者处理完了,提交 offset 时网络抖动失败,消息会被重新投递。这不是缺陷,而是”处理”和”确认”两个动作无法原子化的必然结果。

第四条则说明了一件关键的事:靠请求 ID 做幂等,挡不住用户重复点击。

幂等键的设计

幂等键的选择是整个设计里最容易出错的一步。

请求唯一性 vs 业务唯一性。 一个常见的做法是让客户端生成一个 UUID 作为 request_id,服务端用它做去重:

POST /api/orders
Idempotency-Key: 8f14e45f-ceea-467a-9982-9b1c2f3d4e5a

这能挡住客户端重试和网关重传——因为这两者会复用同一个 request_id。但用户双击按钮时,前端通常会重新生成一个 UUID,服务端收到两个不同的键,去重失效,生成两笔订单。

所以幂等键应该由业务要素构成,而不是由一次网络请求构成:

idempotency_key = hash(user_id + sku_id + cart_snapshot_hash + 时间窗口)

或者更实用的做法:加一个”幂等窗口”。同一个用户对同一个商品,在 5 分钟内只允许创建一笔订单,键就是 (user_id, sku_id, floor(now/5min))。时间窗口是个务实的妥协——它牺牲了”用户真的想在 5 分钟内买两次”的极端场景,换来了实现的简洁和防护的确定性。

哪些要素必须进键: 操作主体(用户/租户)、操作对象(商品/账户)、操作类型、业务时间窗口。哪些不该进: 随机值、纳秒时间戳、进程 ID、机器名——任何会让”同一个意图”产生不同键的东西。

三种实现方式

去重表 + 唯一约束

最通用的方案,适合”创建类”操作。

CREATE TABLE idempotency_record (
    idempotency_key VARCHAR(128) NOT NULL,
    biz_type        VARCHAR(32)  NOT NULL,
    status          TINYINT      NOT NULL DEFAULT 0,  -- 0处理中 1成功 2失败
    result_snapshot JSON,
    created_at      DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    updated_at      DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
    PRIMARY KEY (biz_type, idempotency_key)
) ENGINE=InnoDB;

流程是:先插入一条 status=0 的记录,插入成功才执行业务,执行完更新为 status=1 并写入结果快照。插入失败(唯一键冲突)说明有人在处理或者已经处理过:

public Result createOrder(CreateOrderCmd cmd) {
    String key = buildIdempotencyKey(cmd);
    try {
        idempotencyMapper.insertProcessing("order.create", key);
    } catch (DuplicateKeyException e) {
        IdempotencyRecord exist = idempotencyMapper.get("order.create", key);
        if (exist.getStatus() == SUCCESS) {
            return Result.duplicate(exist.getResultSnapshot()); // 返回首次结果
        }
        if (exist.getStatus() == PROCESSING) {
            return Result.reject("处理中,请稍后重试");
        }
        // 失败态:允许重试,抢占这条记录
        int n = idempotencyMapper.casStatus(key, FAILED, PROCESSING);
        if (n == 0) return Result.reject("处理中,请稍后重试");
    }

    try {
        Order order = doCreate(cmd);
        idempotencyMapper.markSuccess("order.create", key, snapshotOf(order));
        return Result.ok(order);
    } catch (Exception ex) {
        idempotencyMapper.markFailed("order.create", key);
        throw ex;
    }
}

result_snapshot 让重复请求返回首次结果而非一句”重复请求”,用户刷新页面后依然能看到订单号。

状态机前置校验

适合”更新类”操作,尤其是已经有状态流转的领域模型。

-- 只有 pending 状态的订单才能被支付,天然幂等
UPDATE orders
SET status = 'paid', paid_at = NOW(), pay_txn_id = ?
WHERE order_id = ?
  AND status = 'pending';
-- 影响行数 0:要么已支付,要么订单不存在,都拒绝

关键在 WHERE status = 'pending' 这个条件。第二次执行时状态已经是 paid,影响行数为 0,直接拒绝。数据库的行锁保证了并发下只有一个事务能改成功。

如果改完了还需要区分”已支付”和”不存在”,再查一次即可,此时查到的状态是可信的终态。

Token 预领取

适合跨页面、跨步骤的操作,典型是提交表单。

1. 进入表单页  ->  GET /api/form-token  ->  服务端生成 token 存 Redis(TTL 10min),返回给前端
2. 提交表单    ->  POST /api/submit  携带 token
3. 服务端      ->  DEL token,删除成功才继续;删除失败说明已提交过
-- Redis 原子删除,返回 1 表示首次提交
local n = redis.call('DEL', KEYS[1])
return n

用 DEL 而不是 GET 再 DEL,是因为后者在并发下两个请求可能都读到 token 存在。删除本身是原子的检查加获取。

并发下的竞态

这是最经典的坑,值得单独拆开:

// 错误示范:先查后插
if (idempotencyMapper.exists(key)) {
    return Result.duplicate();
}
idempotencyMapper.insert(key);   // 两个请求同时走到这里,都插入成功
doBusiness();

SELECT 和 INSERT 之间存在时间窗口,两个并发请求都会通过检查。在默认的 READ COMMITTED 隔离级别下,这两个事务看不到对方的未提交数据,检查形同虚设。

唯一约束是唯一可靠的答案。 让数据库用唯一索引做仲裁,应用层捕获冲突异常。这也解释了为什么去重策略必须落在有唯一约束的存储上——用 Redis 做去重要用 SET NX,用数据库做去重要用 UNIQUE INDEX,本质都是在依赖某个系统提供的原子性。

另一个容易漏的竞态:业务执行成功、更新去重表状态失败。下一次请求会看到 PROCESSING 并拒绝,而业务其实已完成。缓解手段是给 PROCESSING 记录加超时,超时后允许重新抢占并做业务层校验(比如再查一次订单是否存在)。

幂等键的过期与清理

去重表会无限增长。清理策略有几种:

-- 按业务保留期清理,比 TTL 更可控
DELETE FROM idempotency_record
WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)
LIMIT 5000;
  • 保留多久取决于重试窗口。 支付类通常保留到对账周期结束;普通创建接口几小时到几天。
  • 用分区表按天删除比 DELETE 高效得多,直接 DROP PARTITION,不留碎片。
  • 不要把去重键和业务主键混在一张表里。 去重记录是技术设施,生命周期和业务数据不同步,混在一起会让归档变得极其别扭。

哪些天然幂等,哪些不可能

设计接口时,先判断它的数学性质,能省掉大量无用功。

天然幂等的: 赋值(SET name = 'x')、删除(DELETE WHERE id = 1,第二次影响行数为 0)、按主键更新、幂等的查询、状态设置为终态。

不可能幂等的: 自增(UPDATE t SET count = count + 1)、累加余额、追加日志、INSERT 无唯一约束的记录、发送通知类副作用。

对后者,要么改造语义,要么显式去重。改造语义的一个典型例子:把”余额增加 100 元”改成”记账流水 T123 已入账”,后者可以通过流水号的唯一约束实现幂等。这是把非幂等操作转成幂等操作的通用手法——引入一个稳定的事实标识,让”做了没有”变成可判定的。

一个务实的分层

从外到内,防护可以分成三层,每一层挡住不同的重复来源:

  • 网关层:基于 Idempotency-Key 头做短窗口(例如 60 秒)去重,挡掉网络重传。
  • 应用层:业务幂等键加去重表或状态机校验,挡掉客户端重试和消息重复投递。
  • 数据层:唯一约束兜底,保证即使前两层被绕过,数据也不会错。

三层里只有数据层是必须的,另外两层是性能和体验优化。想清楚这一点,就能避免在网关层堆砌复杂的去重逻辑,也能在资源有限时知道该先做哪一层。

918Views
54Likes
0Comments
0Participants
14 minread time

Comments (0)

No comments yet — be the first.