唯一业务id+数据库唯一约束是最可靠的幂等方案:先插入含biz_id唯一索引的日志表,成功则执行扣减,失败(重复键)即返回已处理,避免并发超卖。

为什么直接用数据库 UPDATE 就可能出错
扣减库存接口若没做幂等,重复请求会导致超卖。常见错误是只靠 UPDATE stock SET quantity = quantity - 1 WHERE id = ? AND quantity >= 1 判断,看似安全,但并发下多个事务同时读到相同 quantity,都满足条件,结果扣成负数。
根本问题在于:SQL 的 WHERE 条件判断和更新不是原子的——它依赖快照隔离级别下的“读-改-写”过程,而 MySQL 默认的 REPEATABLE READ 并不能阻止这种竞态。
- 即使加了唯一索引或乐观锁版本号,若客户端重试逻辑没配合,仍可能因网络超时误判失败后重发
- 分布式环境下,单库事务无法覆盖服务间调用链路,比如订单服务调库存服务,库存服务自身必须独立保证幂等
- 用 Redis + Lua 做预占再落库,若 Lua 脚本没校验库存余量或没设过期时间,会引发脏数据或死锁占用
用唯一业务 ID + 数据库唯一约束最稳
核心思路:把“是否已执行过”这个状态,变成一条不可重复插入的记录。只要插入成功,就代表本次扣减合法;插入失败(duplicate key),说明已处理过,直接返回成功。
表结构示例:
CREATE TABLE inventory_deduction_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id VARCHAR(64) NOT NULL COMMENT '业务唯一ID,如 order_123456', sku_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_id (biz_id) );
-
biz_id必须由上游生成并透传(如订单号),不能在库存服务里生成 UUID,否则重试时无法复用 - 扣减逻辑要先
INSERT INTO inventory_deduction_log,仅当err == nil或mysql.ErrNoRows(实际是 Duplicate entry 错误)才继续扣库;其他错误(如连接超时)需原样返回,让上游决定是否重试 - 不要在
INSERT后再查一遍 log 表判断是否已存在——这又引入竞态,唯一约束才是唯一可信防线
Redis 预占 + MySQL 最终一致性怎么配
高并发场景下纯 DB 写压力大,常用 Redis 做前置校验。但要注意:Redis 只能用于“快速拒绝”,不能替代 DB 的最终幂等保障。
典型流程:SETNX inventory:sku_123:order_456 1 EX 300 → 成功则走 DB 扣减 + 写 log;失败则直接返回“已处理”。
-
EX 300是必须的,防止节点宕机导致 key 永久残留,阻塞后续合法请求 - Redis 和 MySQL 之间没有事务,所以即使 Redis 预占成功,DB 写 log 失败,也不能自动回滚 Redis key——得靠补偿任务定期清理过期/孤立 key
- 不要用
INCR或DECR直接操作库存值,因为 Redis 值不带业务上下文,无法区分“同一订单两次扣减”和“两个订单各扣一次”
Go 代码里怎么组织这个逻辑
关键不是写多 fancy 的封装,而是把“幂等判定”和“业务执行”严格分层。推荐用一个函数接收 bizID、skuID、quantity,返回 bool, error:
func (s *InventoryService) Deduct(ctx context.Context, bizID string, skuID int64, qty int) (bool, error) {
// 1. 尝试插入幂等日志
if err := s.repo.InsertDeductionLog(ctx, bizID, skuID, qty); err != nil {
if errors.Is(err, repo.ErrDuplicateBizID) {
return true, nil // 已处理,幂等成功
}
return false, err
}
// 2. 执行真实扣减(含库存校验)
ok, err := s.repo.DecreaseStock(ctx, skuID, qty)
if err != nil {
// 注意:这里不能简单 rollback log 表!因为 log 插入成功即代表“已受理”,哪怕扣减失败也要留痕供排查
return false, err
}
return ok, nil
}
- 不要把
InsertDeductionLog和DecreaseStock放进同一个事务——MySQL 事务跨 DML 和 INSERT 不解决分布式问题,反而增加锁等待 - 返回
true, nil表示“本次请求已生效或已存在”,调用方应据此跳过后续流程;返回false, err表示失败,由上游决定是否重试 - 日志表里的
biz_id字段一定要加索引,且长度别超过 64 字符,避免索引失效
真正难的不是代码怎么写,而是上下游对 biz_id 的生成规则、重试策略、超时设置达成一致——这些不在 Go 代码里,却决定幂等是否真起作用。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











