如何防止重复支付(尤其在数据未落库时的并发情况)?
1 个回答
纵然前路迷茫,纵然天隔一方,纵然我手中的刀刃已然破碎,也无法阻挡我寻找你的道路。药老# 一、总体思路(一句话) **端到端幂等**:前端携带 `idempotencyKey`,入口用 **Redis 原子占坑** 拦并发,库内用 **唯一约束/乐观锁** 定终局,回调与消息再做一次 **幂等去重**。任何一步失败都不会产生“双扣/双单”。 # 二、入口拦截:数据未落库前的并发控制(关键) 目的:两个并发请求**尚未插入数据库**时,就要让后发的直接失败/等待。 ### 2.1 客户端幂等凭证 * 生成规则:`idempotencyKey = SHA256(userId + orderNo + payAttemptNo + timestampNonce)` * 要求:**重试必须携带同一个 key**(前端/APP或网关保证透传) ### 2.2 Redis 原子占坑(SETNX + TTL) * 维度:**订单维度**或\*\*(orderNo, attemptNo)\*\* 维度 * 原子指令: ``` SET pay:lock:{orderNo}:{attemptNo} 1 NX EX 60 ``` * 成功 → 本次请求是“唯一合法持有者”,继续执行业务 * 失败 → 说明**已有并发在处理**,直接返回“处理中/幂等返回” > 说明:这一步就是为“未落库时”兜底。即使应用在拿到锁后崩溃,**TTL 会自动释放**,不会永久锁死;你也可以用**续租**避免超时(如处理 > 60s)。 ### 2.3 API 层“SingleFlight” * 同一个 `(orderNo, attemptNo)` 的并发仅放行一个协程执行,其余协程**共用结果**(Go 有 singleflight,Java 可用本地 Caffeine+CompletableFuture 实现)。 * 这层是**进程内降并发**,与 Redis 锁形成“双保险”。 # 三、业务落库:幂等 & 约束(终局锚点) 进入事务后,靠**唯一约束 + 乐观锁**让数据库成为最终判官。 ### 3.1 唯一约束(强烈建议) ```sql -- 一单一付或“一单多次尝试但不可并发” ALTER TABLE payment ADD UNIQUE uk_order_attempt (order_no, attempt_no); -- 幂等日志(任何关键动作都要打点) CREATE TABLE idempotent_log ( biz_key VARCHAR(128) PRIMARY KEY, -- 如 PAY:CREATE:{idempotencyKey} created_at DATETIME NOT NULL ); ``` ### 3.2 事务内写法(Outbox 可选) ```java @Transactional public PayResp createPayment(Order order, String idemKey){ tryInsertIdem("PAY:CREATE:"+idemKey); // INSERT idempotent_log,失败直接幂等返回 insertIntoPayment(orderNo, attemptNo, amount, PROCESSING); // 受 uk_order_attempt 保护 insertOutbox("PAY_CREATED", payload); // 与支付单同事务提交 return accepted(); } ``` * 若并发两次到库:**第二次会被唯一约束拒绝** → 幂等返回同一结果。 ### 3.3 状态机只前进 + 乐观锁 ```sql -- 仅允许 PROCESSING -> SUCCESS/FAIL UPDATE payment SET state=?, version=version+1, channel_txn_id=? WHERE pay_no=? AND state IN (PROCESSING) AND version=?; ``` # 四、对外通道:回调/通知也要幂等 通道回调可能“**重复、乱序、晚到**”。 ### 4.1 回调幂等键 ```sql ALTER TABLE callback_log ADD UNIQUE uk_cb (channel, channel_txn_id); -- 防重复回调 ``` ### 4.2 回调处理流程 1. 验签 + 防重放 2. `INSERT callback_log ...`(唯一约束兜底,已处理直接200) 3. **CAS 推进状态机**(仅从 PROCESSING 前进) 4. 产出 outbox 事件 → 下游账务入账(消费者端再做一次幂等) # 五、消息环节:至少一次 → 业务侧幂等 * 使用 **Outbox / 事务消息**,与本地事务一起提交,确保“要么都成功,要么都失败并可重试”。 * 消费者侧统一 `idempotent_log` 去重: ```sql INSERT INTO idempotent_log(biz_key) VALUES ('CONSUME:'+msgKey); -- 冲突即已处理,直接 ack ``` # 六、异常与补偿:防“锁丢/未落库” * **应用崩溃在落库前**:Redis 锁过期释放;客户端重试(同 `idempotencyKey`)会再次 SETNX 成功并继续。 * **锁早过期但业务未完成**:执行期**定时续租**(Lua 脚本 + value 校验持有者)。 * **通道已成功、我们未落账**:T+0/T+1 对账修复(**账务入账幂等**),或支付查询补偿任务。 # 七、完整时序(文字版) 1. 客户端带 `idempotencyKey` 发起支付 2. 网关/应用: * `SETNX pay:lock:{orderNo}:{attemptNo}` 成功 → 继续;失败 → 返回“处理中/已受理” 3. 事务内:`INSERT idempotent_log` → `INSERT payment(PROCESSING)`(命中唯一约束则幂等返回)→ 可选 `INSERT outbox` 4. 调通道,下单成功/失败 → 更新 `payment`(CAS 前进) → outbox 事件 5. 回调/查询:`callback_log` 幂等 → 推进状态机(CAS) → 事件 6. 消费者:依据 `idempotent_log` 入账(双重记账),保证**恰好一次业务效果** # 八、可直接用的关键片段 **Redis 锁(带续租标识)** ```lua -- 加锁 SET key value NX EX 60 -- 续租(只有持有者才能续) if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('EXPIRE', KEYS[1], ARGV[2]) else return 0 end ``` **SQL 约束** ```sql ALTER TABLE payment ADD UNIQUE uk_order_attempt (order_no, attempt_no); CREATE TABLE idempotent_log (biz_key VARCHAR(128) PRIMARY KEY, created_at DATETIME NOT NULL); CREATE TABLE callback_log (channel VARCHAR(16), channel_txn_id VARCHAR(64), PRIMARY KEY(channel, channel_txn_id)); ``` **回调幂等伪码** ```java if (!insertIfAbsent("CALLBACK:"+channel+":"+txnId)) return ok(); advanceStateIfAllowed(payNo, nextState, version); // 只前进,CAS失败即重试查询 ``` # 九、面试速答版(10 秒) > “入口用 **Redis SETNX+TTL** 抢占**订单级锁**,解决**未落库**时的并发;库内靠 **唯一约束+幂等日志+状态机只前进** 定终局;通道回调和消息再做**幂等去重**。整链路端到端幂等,允许至少一次投递但业务效果**恰好一次**。”发布于 2025-08-10 02:31 •IP属地 河北举报评论收藏
公众号进入网站体验更佳

