小Cの已经记不起来的博客

从零实现一个简易的幂等设计

前几天线上遇到一个很烦的事,支付回调收到两次,订单竟然被创建了两遍。我翻了半天日志,发现上游重试时带了同一个 trade_no,但我们的接口压根没判断,直接 insert。那会儿我就想,幂等这玩意儿到底要多“简易”才算真的能用?

下面是我后来在业务接口里加的一版做法,主要靠三个东西:唯一业务号、Redis 临时占位、MySQL 唯一索引。不算多高深,但确实能挡住大部分重复提交、网关重试和 MQ 重复消费。

先别急着加锁,想清楚“同一个请求”怎么定义

很多人一上来就说加锁,或者搞个分布式锁。但幂等最麻烦的不是锁,是你怎么知道这两个请求算“同一个”。

比如用户点了两次“创建订单”,前端可能真的发了两个请求。如果你只有一个用户 ID,那这两个请求没法区分。所以接口最好带一个幂等键,比如:

curl -X POST https://api.example.com/orders \
  -H "Idempotency-Key: order_20260914001" \
  -H "Content-Type: application/json" \
  -d '{"user_id":123,"sku":"A-100","num":1}'

这个 key 可以叫 request_idtrade_nomessage_id,名字无所谓,关键是它得由调用方决定,而且同一次业务重试必须一模一样。

如果调用方乱传,那后端也很难受。所以通常我会再记一个 request_hash,对请求参数做个哈希。参数变了,就不算同一个幂等请求。

一张幂等记录表,先把重复请求“钉住”

最简单可靠的做法,是专门有一张幂等记录表。它不负责业务,只负责回答:这个 key 来过没有?处理成功没有?处理结果是什么?

CREATE TABLE `idempotent_record` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `idem_key` VARCHAR(128) NOT NULL,
  `biz_type` VARCHAR(32) NOT NULL DEFAULT '',
  `request_hash` CHAR(64) NOT NULL DEFAULT '',
  `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0 processing 1 success 2 failed',
  `result_json` TEXT NULL,
  `create_time` DATETIME NOT NULL,
  `update_time` DATETIME NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_idem_key` (`idem_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

重点就是这个:

UNIQUE KEY `uk_idem_key` (`idem_key`)

只要数据库唯一索引在,就算 Redis 挂了、应用实例重启了、两个请求同时进来,也能兜住底。

Redis 只用来挡一挡并发,别把它当唯一防线

接口进来后,可以先用 Redis SETNX 抢一个位置。

伪代码大概长这样:

key := "idem:lock:" + idempotencyKey

ok := redis.SetNX(key, reqID, 30*time.Second)
if !ok {
    record := selectIdemRecord(idempotencyKey)

    if record.Status == "success" {
        return record.ResultJson
    }

    return "processing, retry later"
}

defer redis.Del(key)

result, err := doBusiness()
if err != nil {
    return err
}

return result

这里 30s 是个很恶心的点。太短了,业务还没处理完锁就掉了;太长了,服务真挂了,后续重试全被卡住。所以我一般不把 Redis 当最终答案,只把它当成一个快速失败机制。

真正靠得住的还是数据库。

处理请求时,最好在一个事务里完成

比较稳的写法是:

BEGIN;

INSERT INTO idempotent_record (idem_key, biz_type, request_hash, status, create_time, update_time)
VALUES (?, ?, ?, 0, NOW(), NOW());

-- 这里执行业务逻辑,比如创建订单、扣库存、生成支付单

UPDATE idempotent_record
SET status = 1,
    result_json = ?,
    update_time = NOW()
WHERE idem_key = ?;

COMMIT;

如果第二个相同请求进来,INSERT 唯一键冲突,就可以直接查之前的记录:

SELECT status, result_json FROM idempotent_record WHERE idem_key = ?;

查到 success,直接返回缓存结果;查到 processing,告诉调用方还在处理;查到 failed,才考虑要不要允许重试。

这里有个细节:processing 状态一定要超时清理。不然服务中途崩了,记录停在 processing,后面所有重试都会被你挡住。

可以跑个定时任务:

UPDATE idempotent_record
SET status = 2
WHERE status = 0
  AND create_time < NOW() - INTERVAL 10 MINUTE;

业务表自己也要有唯一约束

只做幂等记录还不够,业务表本身也得防止脏数据。

比如订单表,最好有:

UNIQUE KEY `uk_trade_no` (`trade_no`)

库存扣减,也可以做条件更新:

UPDATE stock
SET num = num - 1
WHERE sku = 'A-100'
  AND num > 0;

如果 affected_rows == 0,就知道这次要么库存不够,要么重复执行被拦下了。

支付回调更明显,别只依赖 Redis:

UPDATE orders
SET status = 'PAID',
    paid_time = NOW()
WHERE trade_no = ?
  AND status = 'WAIT_PAY';

重复回调进来时,这条 SQL 更新不到行数。那就可以直接返回“订单已处理”。对支付渠道来说,回调成功和重复通知一样都是成功,它不关心你内部是不是真的改了状态。

对应需要替换的 订单服务 配置文件

idempotency:
  enabled: true
  key_header: Idempotency-Key
  redis_ttl: 30s
  db_timeout_clean_seconds: 600
  return_original_result: true

不出问题的话,这套就够用了。ctrl+c 重启服务,再拿同一个 Idempotency-Key 连续请求两次,应该就能在 idempotent_record 里看到一条记录。第二次不会再生成订单,只会返回第一次结果。

当然,真到复杂场景里,比如跨服务事务、分库分表、MQ 重复消费,这个简易设计还得继续加东西。但至少从 0 开始把“重复请求别重复写库”这件事解决掉,不需要一上来就把系统设计得特别吓人。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放