Administrator
发布于 2019-10-18 / 749 阅读
5

接口幂等性设计的几种方案

一个用户被扣了两笔钱:关于幂等我踩过的坑

10 月 12 号下午,客服转来投诉:用户买了一双 899 的鞋,银行卡扣了两次款,订单表里却只有一条订单。

查下来是这样的:用户点"立即支付"时网络抖了一下,前端没收到响应,自动重试了一次。两次请求都打到了支付回调接口,回调里没有做幂等,于是扣款发生了两次。

我一开始觉得"加个 Redis 锁就行了",真动手才发现这事儿没那么简单。前后改了三版,把四种方案的取舍都过了一遍。

先明确什么叫幂等

数学上的定义是 f(f(x)) = f(x)。放到接口上:同一个请求执行一次和执行 N 次,对系统状态的影响是一样的

要注意"同一个请求"这个前提,怎么定义"同一个"是设计的核心。我们的做法是由客户端生成一个幂等号(idempotent key),通常和业务主键绑定:

  • 支付场景:订单号 + 支付流水号
  • 下单场景:用户 ID + 前端生成的 requestId(UUID)
  • 消息消费:MQ 的 msgId 或业务主键

另外要区分幂等和防重:查询、删除天然幂等(删一次和删多次结果一样);INSERT、金额累加、状态流转需要额外处理。我们这次出问题的就是金额扣减。

方案一:数据库唯一索引

最简单粗暴,也最可靠。建一张防重表,或者直接在业务表上加唯一索引。

CREATE TABLE payment_record (
    id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    pay_no        VARCHAR(64)  NOT NULL COMMENT '支付流水号',
    order_no      VARCHAR(64)  NOT NULL COMMENT '订单号',
    amount        DECIMAL(12,2) NOT NULL,
    status        TINYINT      NOT NULL DEFAULT 0,
    create_time   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uk_pay_no (pay_no)        -- 关键在这一行
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

代码里直接 insert,靠数据库的唯一约束挡住重复:

@Transactional(rollbackFor = Exception.class)
public PayResult pay(PayRequest req) {
    PaymentRecord record = buildRecord(req);
    try {
        paymentRecordMapper.insert(record);      // 唯一的插入点
    } catch (DuplicateKeyException e) {
        // 已经处理过,直接查出来返回
        log.warn("duplicate payment, payNo={}", req.getPayNo());
        PaymentRecord exist = paymentRecordMapper.selectByPayNo(req.getPayNo());
        return PayResult.success(exist.getPayNo());
    }
    // 走到这里才是第一次处理,执行真正的扣款
    boolean ok = bankClient.deduct(req);
    if (!ok) {
        throw new BizException("扣款失败");      // 抛异常让事务回滚,记录一并回滚
    }
    paymentRecordMapper.updateStatus(record.getId(), PAID);
    return PayResult.success(req.getPayNo());
}

这个方案的可靠性来自数据库,不依赖任何中间件,是我们最终采用的主方案。

几个必须注意的点:

  • catch 的一定是 DuplicateKeyException,不要 catch Exception。否则扣款失败、字段超长这些异常也被当成"重复请求"吞掉,问题会被掩盖。
  • insert 和后续的业务操作必须在同一个事务里。如果扣款失败抛异常,那条防重记录要跟着回滚,否则下次重试会被误判成重复。
  • MySQL 8.0 的 INSERT ... ON DUPLICATE KEY UPDATE 也能做,但它有个副作用:即使没有实际更新,affectedRows 也可能返回 1 或 2(取决于有没有变化),判断"是不是第一次插入"要小心。我更喜欢直接 insert 然后 catch 异常,语义更清晰。

缺点是:唯一索引会让写入多一次唯一性检查,我们压测下来单表插入 QPS 从 8400 降到 7100,降了 15%。另外分库分表之后,唯一索引只能保证单库单表唯一,跨库的幂等要另想办法(比如用 gen 出来的全局 ID 做路由,保证同一个 key 落到同一个分片)。

方案二:Token 机制

适合前端能配合的写操作,比如下单、提交表单。流程是两段式:

  1. 进入页面时,前端先调 /token/apply 拿一个 token,服务端存进 Redis 并设过期时间
  2. 提交表单时带上这个 token,服务端校验并删除,删除成功才处理业务
// 第一步:申请
@GetMapping("/token/apply")
public String applyToken() {
    String token = UUID.randomUUID().toString().replace("-", "");
    String key = "idem:token:" + token;
    redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
    return token;
}

第二步的校验必须用原子操作。我第一版写成"先 GET 再 DEL",压测时 100 并发下放过去了 7 个请求:两个线程同时 GET 到 token 存在,都去删,都删成功。

// 错误写法:GET 和 DEL 之间有窗口
if (redisTemplate.hasKey(key)) {
    redisTemplate.delete(key);
    // 并发下多个线程都能走到这里
}

正确做法是用 Lua 脚本,把"判断 + 删除"变成一次原子操作:

private static final String CONSUME_TOKEN_LUA =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "    return redis.call('del', KEYS[1]) " +
        "else " +
        "    return 0 " +
        "end";

private final RedisScript<Long> consumeTokenScript =
        RedisScript.of(CONSUME_TOKEN_LUA, Long.class);

public boolean consumeToken(String token) {
    String key = "idem:token:" + token;
    Long result = redisTemplate.execute(consumeTokenScript,
            Collections.singletonList(key), "1");
    return Long.valueOf(1).equals(result);      // 返回 1 表示删除成功,是首次请求
}

业务代码里这么用:

@PostMapping("/order/create")
public Result createOrder(@RequestHeader("Idempotent-Token") String token,
                          @RequestBody OrderRequest req) {
    if (!consumeToken(token)) {
        // 拿不到 token:要么用过了,要么过期了
        return Result.fail("请勿重复提交");
    }
    return orderService.create(req);
}

这个方案的缺点是强依赖前端配合。如果调用方是别的系统(我们这次的支付回调就是银行调的),根本没法要求对方先申请 token。而且 Redis 挂了或者 token 过期,业务就走不通了,需要降级。

方案三:状态机 + 乐观锁

这是我个人最喜欢的方案,因为它把幂等和业务状态绑定在一起,不需要额外的存储。

核心是那句带条件的 UPDATE:

<!-- OrderMapper.xml -->
<update id="updateStatusWithCheck">
    UPDATE orders
       SET status = #{targetStatus},
           update_time = NOW()
     WHERE order_no = #{orderNo}
       AND status = #{expectStatus}
</update>
@Transactional(rollbackFor = Exception.class)
public void payCallback(String orderNo, String payNo) {
    // CAS 式更新:只有当前状态是 WAIT_PAY 才更新成 PAID
    int rows = orderMapper.updateStatusWithCheck(orderNo,
            OrderStatus.WAIT_PAY.getCode(), OrderStatus.PAID.getCode());

    if (rows == 0) {
        // 没更新到:要么订单不存在,要么已经支付过了
        Order order = orderMapper.selectByOrderNo(orderNo);
        if (order == null) {
            throw new BizException("订单不存在");
        }
        log.info("order already handled, orderNo={}, status={}",
                 orderNo, order.getStatus());
        return;              // 幂等返回成功,让 MQ 别再重试
    }

    // rows == 1,说明是我把状态改掉的,执行后续逻辑
    stockService.deduct(orderNo);
    couponService.markUsed(orderNo);
    notifyService.sendPaidMsg(orderNo);
}

MySQL 的 UPDATE 本身是加锁的(当前读 + 行锁),所以并发的多个 UPDATE 会串行执行,只有第一个能匹配到 status = WAIT_PAY,后面的 rows 都是 0。天然幂等,零额外成本。

状态机的定义要写清楚,我们在 OrderStatus 枚举里维护了流转规则:

public enum OrderStatus {
    WAIT_PAY(1, "待支付"),
    PAID(2, "已支付"),
    SHIPPED(3, "已发货"),
    FINISHED(4, "已完成"),
    CANCELED(9, "已取消");

    // 允许的流转路径
    private static final Map<Integer, Set<Integer>> TRANSITIONS = new HashMap<>();
    static {
        TRANSITIONS.put(1, Sets.newHashSet(2, 9));   // 待支付 → 已支付 / 已取消
        TRANSITIONS.put(2, Sets.newHashSet(3, 9));   // 已支付 → 已发货 / 已取消
        TRANSITIONS.put(3, Sets.newHashSet(4));      // 已发货 → 已完成
        TRANSITIONS.put(4, Sets.newHashSet());
        TRANSITIONS.put(9, Sets.newHashSet());
    }

    public static boolean canTransfer(int from, int to) {
        return TRANSITIONS.getOrDefault(from, Collections.emptySet()).contains(to);
    }
}

在 update 之前先校验一次,非法的流转直接拒绝并告警,不用打到数据库。

这个方案的局限:只适用于有状态的实体,且状态是单向流转的。像"给用户加积分"这种累加操作,没有状态可判断,就不适用。而且如果状态需要回滚(比如已支付退回待支付),CAS 就失效了,得配合流水表。

方案四:分布式锁

这是最"通用"也最容易写错的方案。我们的第一版就是它,后来被我换掉了。

public PayResult payWithLock(PayRequest req) {
    String lockKey = "idem:pay:" + req.getPayNo();
    String requestId = UUID.randomUUID().toString();

    try {
        // SET key value NX PX 30000,一条命令完成加锁和设过期时间
        Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
        if (!Boolean.TRUE.equals(locked)) {
            return PayResult.fail("操作正在处理中,请勿重复提交");
        }
        // 执行业务
        return doPay(req);
    } finally {
        // 只能删自己加的锁,所以要比对 value
        String releaseLua =
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "    return redis.call('del', KEYS[1]) " +
            "else return 0 end";
        redisTemplate.execute(RedisScript.of(releaseLua, Long.class),
                Collections.singletonList(lockKey), requestId);
    }
}

三个必须遵守的规则:

  • 加锁和设过期时间必须是一条命令SETNX 之后再 EXPIRE,中间进程崩溃就是死锁。Spring Data Redis 2.1 的 setIfAbsent(key, value, timeout, unit) 就是封装好的一条命令。
  • value 必须是唯一值,释放时比对。否则可能删掉别人的锁(A 的锁超时了,B 加锁成功,A 醒过来把 B 的锁删了)。
  • 过期时间要大于业务最大耗时。我们压测时支付最长耗时 3.2 秒,锁设 30 秒,留了 10 倍余量。

但我最终没用分布式锁做幂等,原因是它有两个解不掉的问题:

一、锁只能挡住并发,挡不住先后。第一次请求处理完了、锁释放了,第二次相同请求进来,锁是空的,它会正常执行一遍。分布式锁保证的是"同一时刻只有一个线程处理",不是"这个请求只处理一次"。要做幂等还得配合其他方案。

二、锁过期时间是个两难题。设短了,业务没跑完锁就失效,别人趁虚而入;设长了,客户端要等很久才拿到"操作处理中"的返回,体验差。我们压测时试过 5 秒,结果 P99 有 1.2% 的请求因为业务超时(下游抖动到 6 秒)被穿透,出现了 3 笔重复。

所以分布式锁的正确定位是:防并发,不防重复。它适合用在"不允许同时操作"的场景(比如防止库存超卖的瞬时并发),而不是幂等。

四种方案对比

方案可靠性性能开销依赖适用场景主要缺点
唯一索引最高写 QPS -15%数据库支付、下单等所有写操作分库分表后只能保证单表唯一
Token 机制一次 Redis 往返(约 0.4 ms)Redis + 前端配合表单提交、防止重复点击第三方回调场景用不了
状态机乐观锁零额外开销有单向状态流转的实体累加类操作不适用
分布式锁低(只防并发)一次 Redis 往返Redis防瞬时并发,如库存超卖挡不住先后重复;过期时间难定

我们最终的方案

按场景分开用,不是一刀切:

  • 支付回调(第三方调用,必须可靠):唯一索引 + 状态机双保险。先 insert 防重表(唯一索引兜底),再 CAS 更新订单状态(状态机)。两条防线,任意一条生效就幂等。
  • 下单接口(前端可控):Token 机制 + 唯一索引。Token 挡住用户重复点击(体验好,能返回"请勿重复提交"),唯一索引保证极端情况下也不出错。
  • 订单状态流转:纯状态机,不额外加东西。
  • 库存扣减:分布式锁(防并发超卖)+ 数据库层面的 UPDATE stock SET num = num - 1 WHERE num > 0(防超卖兜底)。

上线三个月,重复扣款从每月 3 到 5 笔降到 0。压测数据(100 并发重复请求同一个支付流水号 1000 次):

方案实际扣款次数平均耗时P99
修复前(无幂等)847312 ms1,840 ms
仅分布式锁12318 ms1,910 ms
唯一索引 + 状态机1324 ms1,960 ms

性能几乎没损失(多一次 insert,+12 毫秒),因为那次插入的耗时远小于银行接口的调用。

先到这

《接口幂等性设计的几种方案》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考