Home首页 / Question 2: payment callback第 2 题:支付回调
Question 2 · must know · payments第 2 题 · 必背 · 支付链路
WeChat fires the callback three times: ship once, deduct once.微信回调同时来三条:只发一次货、库存只扣一次。
One payment, three callbacks. Two of them must do nothing — and must still answer OK.一次支付,三条回调。其中两条必须什么都不做,而且还得回“成功”。
01Why three callbacks at all为什么会来三条
The payment platform is not rude — it is built to be unreliable. If it does not hear an OK, it keeps knocking.支付平台不是没礼貌,它是按不可靠来设计的:没收到成功应答就一直敲。
- WeChat retries on a schedule: 15s, 15s, 30s, 3m, 10m, 20m, 30m … until it hears success.微信按节奏重试:15 秒、15 秒、30 秒、3 分、10 分……一直敲到你说成功。
- Our own gateway may resend when the response times out.我们回包超时,自己的网关也会再发一次。
- A message queue redelivers when the consumer never acknowledged.消息队列没收到 ack,会重投。
- Two app instances can receive it at the same moment — that is a real race, not a retry.两台机器同时收到,这不是重试,是并发。
So “it will not come twice” is never true. The only useful question is: what happens the second time?所以“不会来两次”永远不成立。唯一有用的问题是:第二次来的时候,会发生什么?
02Layer one: the idempotent table第一层:幂等表(拿唯一索引当锁)
Before doing any work, try to insert a row keyed by the platform trade number. The database rejects the second insert — and that rejection is the lock.干活之前,先拿平台流水号插一行记录。第二次插会被数据库拒掉——这个“拒掉”就是锁。
create table pay_idempotent (
id bigint auto_increment primary key,
out_trade_no varchar(64) not null, -- 微信/支付宝流水号
source varchar(16) not null, -- wechat / alipay
created_at datetime not null,
unique key uk_trade (out_trade_no, source)
);
In code it is just insert, and on DuplicateKeyException return success. The second callback does no work and still tells the platform “done”.代码里就是 insert,撞到 DuplicateKeyException 就直接回成功:第二条回调不干活,却仍然告诉平台“已处理”。
Why not select then insert: two threads can both see “not there”. The unique index is the only thing the database itself polices.为什么不用 select 再 insert:两个线程能同时查出“不存在”。唯一索引是数据库自己把门的东西。
03Layer two: status CAS第二层:状态 CAS(比较再交换)
The idempotent row stops duplicates, but it cannot stop a crash halfway. The order state machine is the second guard:幂等表拦得住重复,拦不住“做到一半崩了”。订单状态机是第二道闸:
update orders
set status = 'PAID', pay_time = now()
where order_no = ? and status = 'PENDING'; -- 只有待支付才能改成已支付
| Rows updated影响行数 | What it means说明 |
|---|---|
| 1 | I won — deduct stock, then ship我抢到了:扣库存、发货 |
| 0 | Someone already did it — just return OK别人处理过:直接回成功 |
| 0, and status is CLOSED0,且状态是已关闭 | Money arrived but the order closed — refund钱到了但单子关了:走退款 |
Check and change live in one statement, so there is no gap for another thread. That is also why retrying after a crash is safe.判断和修改在同一条语句里,中间没有缝给别人插进来。这也是“崩了以后重试是安全的”的原因。
04Layer three: the lock, only where it is needed第三层:锁,只加在该加的地方
A Redisson lock is not the first line of defence. It protects the critical section: read stock, check, deduct, write back.Redisson 锁不是第一道防线,它护的是临界区:读库存、判断、扣减、写回。
- The lock key is the business key:
lock:stock:{skuId}, not the order number.锁的 key 是业务维度:lock:stock:{skuId},不是订单号。 - It is held for a few milliseconds of database work — never across an HTTP call.只包几毫秒的数据库操作,绝不横跨一次 HTTP 调用。
- watchdog renews the lease while the thread lives; if the JVM dies, the lease expires.watchdog 在线程活着时自动续期;JVM 挂了,租约自然过期。
- A distributed lock is not free — if one transaction can do it, skip the lock.分布式锁不是免费的:一个事务能搞定的事,就别上锁。
05Four follow-ups, one line each四个追问,一句话一个
| Follow-up追问 | One-line answer一句话答 |
|---|---|
| Where is the lock, and what is the key?锁加在哪,key 是什么? | Around the stock deduction only; the key is the sku id.只包扣库存那段;key 是 sku id。 |
| What if the holder dies?持锁的进程挂了怎么办? | The lease expires; watchdog stops renewing. Never rely on a bare expire.租约自动过期,watchdog 不再续期。别用裸的 expire。 |
| Master fails over and the lock is lost?主从切换丢锁怎么办? | Fall back on the database: unique key and CAS still hold.靠数据库兜底:唯一键和 CAS 仍然守得住。 |
| Could you do it without a lock?能不能不上锁? | Yes: update stock set qty = qty - 1 where qty > 0, then check the rows.能:update ... where qty > 0,看影响行数。 |
06The 60-second answer60 秒照念稿
Say it in this order照着念,再换成自己的话First: duplicates are normal. The platform retries until it hears success, and two instances can get it at once.先说重复是常态:平台没收到成功就一直重试,两台机器也可能同时收到。
Layer one: insert the trade number into a table with a unique index; a duplicate key just returns success.第一层:拿流水号往唯一索引的幂等表里插,撞键就直接回成功。
Layer two: a status CAS — only PENDING may become PAID; zero rows means someone else already did it.第二层:状态 CAS,只有待支付能改成已支付;影响 0 行说明别人做过了。
Layer three: a Redisson lock keyed by sku id around the deduct and write-back; if it is lost on failover, the first two layers still hold.第三层:Redisson 按 sku 加锁护住扣减写回;就算主从切换丢锁,前两层还兜着。
Stock is deducted once because the CAS decides which callback owns the order, and the idempotent row turns the rest into cheap no-ops.库存只扣一次,是因为 CAS 决定这条回调归谁,幂等表把其余的变成廉价空转。
07How to say it as mine怎么挂到我自己的项目上
Only use what is really on the resume — this hangs on the order system, WeChat Native pay, and Redisson:只挑简历上真有的。这题挂在订单系统、微信 Native 支付、Redisson 上:
- Order system: the state machine plus the unique index on
out_trade_no.订单系统:状态机 +out_trade_no唯一索引。 - WeChat Native pay: the callback is the only place that marks an order paid.微信 Native 支付:回调是唯一把订单改成已支付的地方。
- Redisson: used for the stock critical section, not for the callback entry.Redisson:用在库存临界区,不用在回调入口。
08Three things not to say三句别说
- “I add a lock, done.” — A lock does not survive a crash or a failover.“加个锁就完事了。”——锁挡不住崩溃和主从切换。
- “I check the order status first, then ship.” — That is the race itself.“我先查订单状态,再发货。”——这就是竞态本身。
- “MQ guarantees no duplicates.” — Most queues give at-least-once, not exactly-once.“MQ 能保证不重复。”——多数队列是至少一次,不是恰好一次。
When you do not know a piece, say how you would check: “I would confirm whether the retry body is re-signed…” Honest with a direction beats guessing.真不会的时候,说你会怎么确认:“我会先确认重试报文是否重新签名……”诚实加方向,比硬编强。
→New words新词
Click ▶ hear it for real people on YouGlish; 🔊 for the computer voice.点 ▶ hear it 听真人发音(YouGlish);点 🔊 听电脑朗读。
✓Check yourself自测
- Why must the second callback still answer success?第二条回调为什么必须回成功?
- What does “0 rows updated” mean in the CAS?CAS 影响 0 行是什么意思?
- Name two things that make a retry after a crash safe.崩了以后重试为什么安全?说出两个兜底。
Answer out loud first. Only then open the answers below.先自己出声答,再看下面的答案。
Answers参考答案
09New questions this one opened这道题又长出了哪些新问题
A good answer opens the next question. That is the whole method.一个答案会生出下一个问题——方法就是这个。
| Question新问题 | One line一句话 | |
|---|---|---|
| 为什么用唯一索引当锁,而不是先查再插? | 两个线程能同时查出“不存在”;唯一索引是数据库自己把门的。 | 待写 |
| 幂等记录插进去了,但业务崩了怎么办? | 靠状态 CAS 兜底:只允许 PENDING → PAID,重试时影响 0 行就按已处理返回。 | 待写 |
| 钱到了但订单已经关闭,怎么处理? | CAS 影响 0 行且状态是 CLOSED → 走退款,不能悄悄吞掉。 | 待写 |
| Redisson 的 watchdog 到底在续什么? | 续的是锁的租约;线程活着才续,进程挂掉租约自动过期。 | 待写 |
| 主从切换把锁丢了怎么办? | 靠数据库兜底:唯一索引 + CAS 仍然守得住。 | 待写 |
| MQ 消费重复,和这个是一回事吗? | 机制一样:消费端幂等(业务唯一键 / 状态机),别指望队列只投一次。 | 待写 |
| 那库存怎么保证只扣一次、还不超卖? | 预扣 + 条件更新(qty >= 1)+ 延时关单回滚。 | 待写 |
| 缓存和数据库不一致怎么办? | Cache Aside:写库后删缓存,加延迟补偿与过期兜底。 | 待写 |