mirror of
https://github.com/CyC2018/Interview-Notebook.git
synced 2026-10-31 19:13:42 +00:00
Update 系统设计基础.md
新增订单与支付一章,包括状态机与条件更新、超时关单、支付回调与重复支付、对账、转账与余额扣减,配图两张并附实测。
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
+320
@@ -15,6 +15,7 @@
|
||||
* [十二、权限系统](#十二权限系统)
|
||||
* [十三、系统设计题的思路](#十三系统设计题的思路)
|
||||
* [十四、即时通讯](#十四即时通讯)
|
||||
* [十五、订单与支付](#十五订单与支付)
|
||||
* [附录:实测](#附录实测)
|
||||
* [参考资料](#参考资料)
|
||||
<!-- GFM-TOC -->
|
||||
@@ -1265,6 +1266,116 @@ CREATE TABLE conv_member (
|
||||
|
||||
管理这么多连接要用 epoll 这类 [I/O 复用](Socket.md#二io-复用):实测中一个线程就管理了全部 10 万个连接,每个连接一个线程的话需要 10 万个线程。
|
||||
|
||||
## 十五、订单与支付
|
||||
|
||||
订单与支付的难点不在并发量,而在于订单服务和支付平台之间没有共同的事务:用户在支付平台付款,订单服务只能通过回调和查单得知结果,回调会重复、迟到或丢失,超时关单又可能恰好和付款同时发生。设计的目标是每一笔钱最终都对得上:付了款的订单要么发货、要么退款,没付款的订单一定关闭。
|
||||
|
||||
以用户 1001 在 10:00 下单的订单 1024 为例,金额 59 元,30 分钟内未支付自动关闭。
|
||||
|
||||
### 状态机与条件更新
|
||||
|
||||
订单的每次变化都是状态机上的一条边,只能从规定的前置状态转换过来:
|
||||
|
||||
<div align="center"> <img src="pics/order-state-machine.png" width="760px"/> </div><br>
|
||||
|
||||
每条边写成一条带前置状态的条件更新,影响 1 行才算转换成功,才执行这条边的后续动作,例如发货、回补库存:
|
||||
|
||||
```sql
|
||||
UPDATE orders SET status = 'PAID', pay_txn = 'wx1024'
|
||||
WHERE id = 1024 AND status IN ('UNPAID', 'CLOSING');
|
||||
```
|
||||
|
||||
支付回调、关单任务、用户取消可能同时修改同一个订单。条件更新在行锁的保护下一次完成判断和修改,同一时刻只有一个能成功,其余的影响 0 行,按订单的当前状态处理即可。原理与[防止超卖的条件更新](#防止超卖的几种做法)相同,重复执行影响 0 行,也就实现了[幂等](分布式.md#九幂等)。
|
||||
|
||||
关单中(CLOSING)是一个中间状态,表示已经决定关单、还在等支付平台的结果。终态说明事情已经结束,中间态则记录了“做到一半”,后续的处理都依赖它:
|
||||
|
||||
- 发起支付的接口用条件更新记下“已发起支付”(条件是待支付),看到关单中就不再向平台下单;
|
||||
- 关单任务调用平台后崩溃,扫描任务找到停在关单中超过几分钟的订单,重新执行关单;
|
||||
- 回调在关单中到达时仍然有效,订单改为已支付。
|
||||
|
||||
### 超时关单
|
||||
|
||||
到期触发用延迟消息加扫描兜底:下单时发送一条 30 分钟后投递的消息,再用一个低频的定时任务扫描超时仍未关闭的订单,补上丢失的消息,两者的取舍见[定时扫描与延迟消息](分布式.md#定时扫描与延迟消息)。关单消息要和订单一起成功,可以在同一个本地事务中写入[本地消息表](分布式.md#本地消息表),或者使用事务消息,否则会出现订单已创建、关单消息却没有发出的情况。
|
||||
|
||||
关单的难点是到期时用户可能正在付款。如果只改本地状态(status 从 UNPAID 改为 CLOSED),支付平台上的这笔交易仍然可以支付,用户在 10:30 之后打开旧的支付页面照样能付款,结果是平台已收款、本地已关闭,只能退款。所以关单要以支付平台为准,分三步:
|
||||
|
||||
1. 用条件更新把订单从待支付改为关单中。影响 0 行时查看当前状态:已支付或已关闭就结束,仍是关单中(上一次执行到一半)就继续下一步;
|
||||
2. 调用支付平台的关单接口,关单可以重复调用,返回成功或订单已关闭时,把订单从关单中改为已关闭,在同一个事务中回补库存。用户从未发起过支付的订单,平台上没有这笔交易,直接关闭;
|
||||
3. 关单失败时查单:平台返回已支付,就按支付成功处理,与回调走同一段代码;返回订单不存在,说明平台上没有这笔交易,可以关闭:即使下单请求迟到、平台稍后才建单,交易的截止时间也已经过了,用户无法再付款(截止时间见下文的 time_expire);结果不明时保持关单中,由扫描任务稍后从第 1 步重新执行。
|
||||
|
||||
<div align="center"> <img src="pics/order-close-race.png" width="760px"/> </div><br>
|
||||
|
||||
上图是最容易出错的情况:用户 10:29:58 付款,回调因网络延迟还没到,关单任务 10:30:00 执行,平台返回订单已支付(微信支付 V2 的错误码 ORDERPAID,文档要求“当作已支付的正常交易”),查单拿到交易号后订单改为已支付;10:30:05 回调到达时,这个交易号已经处理过,不会再发一次货。反过来,平台先关单成功,用户就不能再付款。两种先后顺序都收敛到一致的状态。
|
||||
|
||||
300 轮随机交错的实验中(回调 10% 丢失),只改本地状态的写法有 260 轮平台已付、本地已关;先向平台关单、失败只等回调的写法有 22 轮停在待支付,要靠查单或对账补上;按上面三步的写法 300 轮全部一致,都没有重复发货(MySQL 8.0 实测,见[附录 A23](#a23-超时关单与支付回调))。
|
||||
|
||||
下单时还要把同一个截止时间传给支付平台,例如微信支付的 time_expire 设为 10:30,超过后平台拒绝支付。不传时平台默认 7 天内都可以支付,本地 30 分钟的超时就挡不住用户付款。
|
||||
|
||||
### 支付回调与重复支付
|
||||
|
||||
支付结果有两个来源:支付平台主动发送的回调,以及订单服务主动查单。回调可能丢失,平台也要求不能只依赖回调,所以用户回到订单页、关单之前和定时任务都会查单。
|
||||
|
||||
回调会重复发送,例如微信支付在 5 秒内没有收到成功应答,会在约 24 小时内按逐渐拉长的间隔重发,最多 15 次。处理时先验签,再核对通知中的商户号、订单号和金额与本地订单一致,并确认交易状态是支付成功(例如支付宝只有 TRADE_SUCCESS、TRADE_FINISHED 表示已付款,关单、退款也会发通知),然后把去重记录和条件更新放在同一个事务中:
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
INSERT INTO pay_notify (transaction_id, order_id, amount)
|
||||
VALUES ('wx1024', 1024, 5900); -- 交易号为主键,重复的通知报 1062,回滚后应答成功
|
||||
UPDATE orders SET status = 'PAID', pay_txn = 'wx1024'
|
||||
WHERE id = 1024 AND status IN ('UNPAID', 'CLOSING'); -- 影响 1 行才写发货消息
|
||||
INSERT INTO outbox (order_id, event, ref) VALUES (1024, 'SHIP', 'wx1024');
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
同一笔通知并发到达 10 次,先查状态再更新的写法每轮发货 10 次,上面的写法每轮 1 次(见[附录 A23](#a23-超时关单与支付回调))。重复的通知也要应答成功,否则平台会继续重发。
|
||||
|
||||
重复支付指一个订单被付了两次钱,常见的原因有两种:
|
||||
|
||||
- 用户连续点击支付按钮:每次都用订单号 1024 作为商户订单号向平台下单,平台上只有一笔交易,只能付一次;每次点击都换一个新单号的话,平台上就有多笔都能付款的交易。微信支付也要求再次发起支付时使用原单号;
|
||||
- 用户先后用两个渠道付款,例如微信和支付宝各付了一次:第二笔回调到达时,订单已被第一笔改为已支付,条件更新影响 0 行,交易号又与订单记录的不同,这时记下这笔交易并发起退款。
|
||||
|
||||
退款的幂等键是退款单号,例如 R1024-1。超时后重试必须沿用原退款单号,平台保证同一个退款单号只退一笔,换一个新单号可能退两次;部分退款每次用新的退款单号。申请成功只表示平台已受理,退款是否成功以退款回调或查询结果为准。
|
||||
|
||||
### 对账
|
||||
|
||||
回调、查单和关单都可能因为程序缺陷或长时间故障漏掉一些订单,对账是最后的兜底:每天(T+1)下载支付平台前一天的交易账单,本地也取同一渠道、同一天支付成功的订单,按订单号逐笔核对,结果分为四类:
|
||||
|
||||
- 双方都是支付成功:对平,金额或交易号不一致时也算差异,例如订单 1029 在支付宝和微信各付了一次,订单号相同、交易号不同;
|
||||
- 平台成功、本地未成功:例如订单 1025 在本地已关闭,补发货或者退款。是否支付过要看本地保留的交易号,而不是订单的当前状态,已进入退款中的订单也付过款,退款用退款账单另外核对;
|
||||
- 账单中有、本地没有:排查本地数据是否丢失;
|
||||
- 本地成功、账单中没有:排查订单处理的逻辑缺陷,例如把没有验签的通知当成了支付成功。
|
||||
|
||||
核对用一条按订单号的外连接查询就能完成(见[附录 A25](#a25-冻结与对账))。能确定的差异自动处理,其余的转人工;同一类差异反复出现,说明前面的环节有缺陷,要修复程序,而不是每天靠对账补。
|
||||
|
||||
### 转账与余额扣减
|
||||
|
||||
转账要满足三点:余额不能扣成负数,钱的总数不能变,同一个请求不能执行两次。以用户 1 给用户 2 转 100 元为例,两个账户在同一个数据库中时,用一个事务完成:
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
INSERT INTO transfer (request_id, from_id, to_id, amount, status)
|
||||
VALUES ('req-7f3a', 1, 2, 100, 'DONE'); -- 请求号为主键,重复提交报 1062
|
||||
SELECT balance FROM account WHERE id = 1 FOR UPDATE; -- 先锁 id 小的账户
|
||||
SELECT balance FROM account WHERE id = 2 FOR UPDATE;
|
||||
UPDATE account SET balance = balance - 100
|
||||
WHERE id = 1 AND balance >= 100; -- 影响 0 行说明余额不足,回滚
|
||||
UPDATE account SET balance = balance + 100 WHERE id = 2;
|
||||
INSERT INTO account_flow (request_id, account_id, amount, balance_after)
|
||||
VALUES ('req-7f3a', 1, -100, 900), ('req-7f3a', 2, 100, 1100);
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
每一部分解决一个问题:
|
||||
|
||||
- 余额判断写在 UPDATE 的条件中:先读出余额、在应用中算好再写回,两个事务会读到同一个旧值,后写的覆盖先写的。4 个账户共 4000 元、8 个线程并发转账 480 笔,这种写法的余额总和变成了 8566、4398、10265,条件更新始终是 4000(MySQL 8.0 实测,见[附录 A24](#a24-转账));
|
||||
- 按 ID 从小到大加锁:A 给 B 转账的同时 B 给 A 转账,各自先锁付款方,就会互相等待而死锁,过程见 [MySQL 死锁](计算机操作系统%20-%20死锁.md#2-mysql-死锁)。InnoDB 回滚其中一个,重试后余额仍然正确,但上面的实验中每 480 笔出现约 70~100 次死锁(多次运行),按 ID 加锁后为 0 次。这并不是把转账串行化,只有涉及同一个账户的转账才在这一行的锁上排队;
|
||||
- 请求号:由客户端生成,重试时不变,死锁回滚或超时后重试也只会成功一次。同一请求号并发提交 5 次,1 次成功,4 次报 1062。需要注意的是,余额不足时整个事务回滚,请求号也没有留下,要求同一请求号的结果固定时,失败结果要另外记录;
|
||||
- 流水:每次余额变动写一条流水,记下金额和变动后的余额。余额是流水累加的结果,对账时检查“期初余额 + 流水合计 = 当前余额”,绕过流水直接修改余额就会被查出来。
|
||||
|
||||
钱要转到外部时,例如提现到银行卡、转给另一个数据库中的账户,就无法在一个事务中完成,结果可能要等很久才返回。这时先冻结:可用余额减 300、冻结金额加 300(条件是可用余额不少于 300,影响 0 行就不发起转出),这笔钱不会再被其它请求花掉;外部返回成功后扣减冻结金额,返回失败则解冻、加回可用余额。冻结、扣减、解冻对应 [TCC](分布式.md#tcc) 的 Try、Confirm、Cancel,提现单号是主键,与冻结在同一个事务中写入,重复的请求不会再冻结一次;之后每一步都以提现单的状态为条件更新,重复的回调只扣一次(见[附录 A25](#a25-冻结与对账))。
|
||||
|
||||
结果不明(超时、处理中)时既不扣减也不解冻,用原单号查询,查询结果明确为失败后才能解冻或者换新单号重试,否则可能转出两笔。微信支付的商家转账文档也要求接口返回异常时不要立即更换单号重试。
|
||||
|
||||
## 附录:实测
|
||||
|
||||
环境为 Apple M1 Pro、macOS,Redis 7.4.1 使用默认配置,客户端为 redis-py。
|
||||
@@ -2372,6 +2483,206 @@ TCP: inuse 0 orphan 0 tw 0 alloc 200026 mem 1535
|
||||
+----+-------------+---------+------------+-------+---------------+---------+---------+------+------+----------+-------------+
|
||||
```
|
||||
|
||||
### A23 超时关单与支付回调
|
||||
|
||||
环境:MySQL 8.0.44(默认的可重复读),Python 3 + PyMySQL,Apple M1 Pro。库 shop 中的表 orders(id, user_id, amount, status, pay_txn, expire_at, pay_started)、pay_notify(transaction_id 主键, order_id, amount)、outbox(id, order_id, event, ref)。支付平台用内存中的桩代替:只有未支付(NOTPAY)的交易才能付款或关单,关单可以重复调用,已支付时关单返回 ORDERPAID,没有这笔交易时关单和查单都返回 ORDER_NOT_EXIST,已过 time_expire 的交易不能付款。
|
||||
|
||||
每轮新建一个待支付订单,用户在 0~20 ms 内的随机时刻付款,回调在付款后 0~20 ms 到达,10% 的回调丢失;关单任务在 0~20 ms 内的随机时刻执行。三种写法使用同一组随机时刻。回调和关单的写法:
|
||||
|
||||
```python
|
||||
def handle_notify(c, oid, txn, amount): # 支付回调,查单成功时也调用它
|
||||
cur = c.cursor()
|
||||
try:
|
||||
cur.execute("INSERT INTO pay_notify VALUES (%s, %s, %s)", (txn, oid, amount))
|
||||
except pymysql.err.IntegrityError:
|
||||
c.rollback(); return "dup" # 重复的通知,直接应答成功
|
||||
n = cur.execute("UPDATE orders SET status = 'PAID', pay_txn = %s "
|
||||
"WHERE id = %s AND status IN ('UNPAID', 'CLOSING')", (txn, oid))
|
||||
if n == 1:
|
||||
cur.execute("INSERT INTO outbox (order_id, event, ref) VALUES (%s, 'SHIP', %s)", (oid, txn))
|
||||
else: # 已关闭或已被另一笔交易支付:记下待退款
|
||||
cur.execute("SELECT pay_txn FROM orders WHERE id = %s", (oid,))
|
||||
if cur.fetchone()[0] != txn:
|
||||
cur.execute("INSERT INTO outbox (order_id, event, ref) VALUES (%s, 'REFUND', %s)", (oid, txn))
|
||||
c.commit(); return "ok"
|
||||
|
||||
def close_local_only(c, p, oid): # A 只改本地状态
|
||||
c.cursor().execute("UPDATE orders SET status = 'CLOSED' WHERE id = %s AND status = 'UNPAID'", (oid,))
|
||||
c.commit()
|
||||
|
||||
def close_platform_first(c, p, oid): # B 先向平台关单,失败时等回调
|
||||
if p.close(oid) == "OK":
|
||||
c.cursor().execute("UPDATE orders SET status = 'CLOSED' WHERE id = %s AND status = 'UNPAID'", (oid,))
|
||||
c.commit()
|
||||
|
||||
def close_full(c, p, oid): # C 关单中 + 平台关单 + 失败查单
|
||||
cur = c.cursor()
|
||||
n = cur.execute("UPDATE orders SET status = 'CLOSING' WHERE id = %s AND status = 'UNPAID'", (oid,))
|
||||
c.commit()
|
||||
if n == 0:
|
||||
cur.execute("SELECT status FROM orders WHERE id = %s", (oid,))
|
||||
st0 = cur.fetchone()[0]; c.commit()
|
||||
if st0 != "CLOSING":
|
||||
return # 已支付或已关闭;停在关单中的继续执行
|
||||
cur.execute("SELECT pay_started FROM orders WHERE id = %s", (oid,))
|
||||
started = cur.fetchone()[0]; c.commit()
|
||||
if not started or p.close(oid) in ("OK", "ORDERCLOSED"): # 从未发起支付的直接关闭
|
||||
cur.execute("UPDATE orders SET status = 'CLOSED' WHERE id = %s AND status = 'CLOSING'", (oid,))
|
||||
if cur.rowcount == 1:
|
||||
cur.execute("INSERT INTO outbox (order_id, event) VALUES (%s, 'RESTOCK')", (oid,))
|
||||
c.commit()
|
||||
else:
|
||||
st, txn = p.query(oid)
|
||||
if st == "SUCCESS":
|
||||
handle_notify(c, oid, txn, 5900)
|
||||
elif st == "ORDER_NOT_EXIST": # 平台上没有这笔交易,迟到的下单也已过 time_expire
|
||||
cur.execute("UPDATE orders SET status = 'CLOSED' WHERE id = %s AND status = 'CLOSING'", (oid,))
|
||||
if cur.rowcount == 1:
|
||||
cur.execute("INSERT INTO outbox (order_id, event) VALUES (%s, 'RESTOCK')", (oid,))
|
||||
c.commit()
|
||||
```
|
||||
|
||||
最后一行模拟关单任务改为关单中后崩溃:2001 崩溃前已在平台关单成功,2002 在崩溃期间被支付且回调丢失,2003 从未发起支付,2004 发起过支付但平台下单失败,2005 的下单请求在关单之后才到达平台,扫描任务对它们重新执行 close_full;之后平台为 2005 建单,桩按已过 time_expire 拒绝付款。300 轮实验中的订单都已发起支付(pay_started = 1);多次运行时 A、B 两行的数字因线程调度相差几轮。重复回调的对照写法先 SELECT 订单状态,为 UNPAID 时再执行不带条件的 UPDATE 并写发货消息;10 个线程用 Barrier 同时开始。原始输出:
|
||||
|
||||
```text
|
||||
300 轮,用户在 0~20 ms 内付款,回调在付款后 0~20 ms 到达、10% 丢失,关单任务在 0~20 ms 内执行
|
||||
写法 一致 已付已关 已付未付 发货>1
|
||||
A 只改本地状态 40 260 0 0
|
||||
B 先向平台关单,失败等回调 278 0 22 0
|
||||
C 关单中 + 平台关单 + 失败查单 300 0 0 0
|
||||
|
||||
先查后改:每轮发货记录 [10, 10, 10, 10, 10]
|
||||
去重表 + 条件更新:每轮发货记录 [1, 1, 1, 1, 1]
|
||||
2005 迟到的下单之后用户付款: 被平台拒绝
|
||||
停在关单中的订单重新执行: ((2001, 'CLOSED'), (2002, 'PAID'), (2003, 'CLOSED'), (2004, 'CLOSED'), (2005, 'CLOSED')) ((2001, 'RESTOCK'), (2002, 'SHIP'), (2003, 'RESTOCK'), (2004, 'RESTOCK'), (2005, 'RESTOCK'))
|
||||
```
|
||||
|
||||
“已付已关”指平台已支付、本地已关闭,只能退款;“已付未付”指平台已支付、本地仍是待支付,要靠查单或对账补上。随机时刻只是为了制造交错,各写法之间的相对结果有意义,比例的绝对值不代表真实系统。
|
||||
|
||||
### A24 转账
|
||||
|
||||
环境同 A23。表 account(id, balance, frozen)、transfer(request_id 主键, from_id, to_id, amount, status)、account_flow(request_id, account_id, amount, balance_after,(request_id, account_id) 唯一)。4 个账户各 1000,8 个线程各做 60 次随机转账(随机选两个账户,金额 1~300),遇到死锁(1213)时回滚并重试同一笔。三种写法:
|
||||
|
||||
```python
|
||||
def read_write(cur, rid, a, b, x): # 先读后写
|
||||
cur.execute("SELECT balance FROM account WHERE id = %s", (a,)); ba = cur.fetchone()[0]
|
||||
if ba < x: return "short"
|
||||
cur.execute("SELECT balance FROM account WHERE id = %s", (b,)); bb = cur.fetchone()[0]
|
||||
cur.execute("UPDATE account SET balance = %s WHERE id = %s", (ba - x, a))
|
||||
cur.execute("UPDATE account SET balance = %s WHERE id = %s", (bb + x, b))
|
||||
return "ok"
|
||||
|
||||
def cond_update(cur, rid, a, b, x): # 条件更新,先付款方后收款方
|
||||
if cur.execute("UPDATE account SET balance = balance - %s WHERE id = %s AND balance >= %s", (x, a, x)) == 0:
|
||||
return "short"
|
||||
cur.execute("UPDATE account SET balance = balance + %s WHERE id = %s", (x, b))
|
||||
return "ok"
|
||||
|
||||
def sorted_lock(cur, rid, a, b, x): # 请求号 + 按 ID 加锁 + 条件更新 + 流水
|
||||
cur.execute("INSERT INTO transfer VALUES (%s, %s, %s, %s, 'DONE')", (rid, a, b, x))
|
||||
for i in sorted((a, b)):
|
||||
cur.execute("SELECT balance FROM account WHERE id = %s FOR UPDATE", (i,))
|
||||
if cur.execute("UPDATE account SET balance = balance - %s WHERE id = %s AND balance >= %s", (x, a, x)) == 0:
|
||||
return "short"
|
||||
cur.execute("UPDATE account SET balance = balance + %s WHERE id = %s", (x, b))
|
||||
cur.execute("INSERT INTO account_flow (request_id, account_id, amount, balance_after) "
|
||||
"SELECT %s, id, IF(id = %s, -%s, %s), balance FROM account WHERE id IN (%s, %s)", (rid, a, x, x, a, b))
|
||||
return "ok"
|
||||
```
|
||||
|
||||
返回 ok 时提交,余额不足时回滚。最后用同一个请求号 req-7f3a 并发提交 5 次;再跑一次带流水的写法,人为给账户 3 加 50 元而不写流水,执行对账查询:
|
||||
|
||||
```sql
|
||||
SELECT a.id, a.balance, 1000 + COALESCE(SUM(f.amount), 0) AS expected
|
||||
FROM account a LEFT JOIN account_flow f ON f.account_id = a.id
|
||||
GROUP BY a.id, a.balance HAVING a.balance <> expected;
|
||||
```
|
||||
|
||||
原始输出(先读后写同样会死锁,因为两条 UPDATE 也是先付款方后收款方):
|
||||
|
||||
```text
|
||||
4 个账户各 1000,8 个线程各转 60 笔(金额 1~300),死锁时重试,各 3 次
|
||||
写法 成功 余额不足 死锁 总和 最小余额
|
||||
先读后写 468 12 105 8566 1847
|
||||
条件更新,先付款方 416 64 81 4000 558
|
||||
按 ID 排序加锁 + 条件更新 424 56 0 4000 375
|
||||
先读后写 463 17 101 4398 390
|
||||
条件更新,先付款方 409 71 69 4000 222
|
||||
按 ID 排序加锁 + 条件更新 417 63 0 4000 43
|
||||
先读后写 462 18 85 10265 894
|
||||
条件更新,先付款方 428 52 81 4000 37
|
||||
按 ID 排序加锁 + 条件更新 420 60 0 4000 433
|
||||
|
||||
同一请求号并发提交 5 次: ['成功', '重复 1062', '重复 1062', '重复 1062', '重复 1062'] 余额 ((1, 900), (2, 1100))
|
||||
先排序加锁(带流水) 447 33 0 4000 135
|
||||
改余额不写流水后,对账查出: ((3, 185, Decimal('135')),)
|
||||
```
|
||||
|
||||
### A25 冻结与对账
|
||||
|
||||
环境同 A23。账户 1 的可用余额为 1000,提现单 w-1 冻结 300 元:先插入提现单,重复的请求报 1062 时回滚整个事务,返回已有的提现单;冻结影响 0 行时同样回滚,不发起转出。银行的成功回调按提现单状态扣减冻结金额:
|
||||
|
||||
```python
|
||||
def freeze(wid, x):
|
||||
try:
|
||||
cur.execute("INSERT INTO withdraw VALUES (%s, 1, %s, 'PROCESSING')", (wid, x))
|
||||
except pymysql.err.IntegrityError as e:
|
||||
c.rollback(); return f"重复请求 {e.args[0]}"
|
||||
n = cur.execute("UPDATE account SET balance = balance - %s, frozen = frozen + %s "
|
||||
"WHERE id = 1 AND balance >= %s", (x, x, x))
|
||||
if n == 0:
|
||||
c.rollback(); return "余额不足,回滚"
|
||||
c.commit(); return "已冻结,发起转出"
|
||||
|
||||
def on_success(wid, x):
|
||||
n = cur.execute("UPDATE withdraw SET status = 'SUCCESS' WHERE id = %s AND status = 'PROCESSING'", (wid,))
|
||||
if n == 1:
|
||||
cur.execute("UPDATE account SET frozen = frozen - %s WHERE id = 1", (x,))
|
||||
c.commit(); return f"提现单影响 {n} 行"
|
||||
```
|
||||
|
||||
```text
|
||||
w-1 冻结 300: 已冻结,发起转出
|
||||
冻结后 balance 700 frozen 300 withdraw (('w-1', 'PROCESSING'),)
|
||||
w-1 重复提交: 重复请求 1062
|
||||
重复提交后 balance 700 frozen 300 withdraw (('w-1', 'PROCESSING'),)
|
||||
w-2 冻结 5000: 余额不足,回滚
|
||||
余额不足后 balance 700 frozen 300 withdraw (('w-1', 'PROCESSING'),)
|
||||
成功回调 1: 提现单影响 1 行
|
||||
成功回调 2: 提现单影响 0 行
|
||||
两次回调后 balance 700 frozen 0 withdraw (('w-1', 'SUCCESS'),)
|
||||
```
|
||||
|
||||
对账在 mysql 客户端中执行。orders 加上渠道 channel 和支付时间 paid_at 两列,本地有 1020(10 月 28 日支付)和 1024~1029,其中 1025 已关闭,1028 支付后正在退款,1029 先用支付宝付款、微信又付了一次且回调丢失;10 月 29 日的微信支付账单 bill(out_trade_no, transaction_id, amount, trade_state) 中有 1024、1025、1026、1028、1029,1026 的金额与本地不同。是否支付过看保留的交易号 pay_txn,而不是订单的当前状态,否则退款中的 1028 会被误判;查找“本地成功、账单无”时本地只取同一渠道、同一天支付的订单,1020 不会被误报:
|
||||
|
||||
```sql
|
||||
SELECT b.out_trade_no, o.id AS local_id, b.amount AS bill_amount, o.amount AS local_amount, o.pay_txn,
|
||||
CASE WHEN o.id IS NULL THEN '账单有、本地无'
|
||||
WHEN o.pay_txn IS NULL THEN '平台成功、本地未成功'
|
||||
WHEN o.pay_txn <> b.transaction_id THEN '交易号不符'
|
||||
WHEN b.amount <> o.amount THEN '金额不符' ELSE '对平' END AS result
|
||||
FROM bill b LEFT JOIN orders o ON o.id = b.out_trade_no
|
||||
UNION ALL
|
||||
SELECT NULL, o.id, NULL, o.amount, o.pay_txn, '本地成功、账单无'
|
||||
FROM orders o LEFT JOIN bill b ON b.out_trade_no = o.id
|
||||
WHERE o.pay_txn IS NOT NULL AND o.channel = 'wxpay'
|
||||
AND o.paid_at >= '2026-10-29' AND o.paid_at < '2026-10-30'
|
||||
AND b.out_trade_no IS NULL;
|
||||
```
|
||||
|
||||
```text
|
||||
+--------------+----------+-------------+--------------+---------+------------------------+
|
||||
| out_trade_no | local_id | bill_amount | local_amount | pay_txn | result |
|
||||
+--------------+----------+-------------+--------------+---------+------------------------+
|
||||
| 1024 | 1024 | 5900 | 5900 | wx1024 | 对平 |
|
||||
| 1025 | 1025 | 3900 | 3900 | NULL | 平台成功、本地未成功 |
|
||||
| 1026 | 1026 | 1100 | 1200 | wx1026 | 金额不符 |
|
||||
| 1028 | 1028 | 4200 | 4200 | wx1028 | 对平 |
|
||||
| 1029 | 1029 | 3300 | 3300 | ali1029 | 交易号不符 |
|
||||
| NULL | 1027 | NULL | 2500 | wx1027 | 本地成功、账单无 |
|
||||
+--------------+----------+-------------+--------------+---------+------------------------+
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Redis: Scripting with Lua](https://redis.io/docs/latest/develop/programmability/eval-intro/)
|
||||
@@ -2398,6 +2709,15 @@ TCP: inuse 0 orphan 0 tw 0 alloc 200026 mem 1535
|
||||
- [Circuit Breaker pattern(Azure 架构中心)](https://learn.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker)
|
||||
- [阿里云 PTS:线上业务压测的核心要素](https://help.aliyun.com/zh/pts/performance-test-pts-2-0/product-overview/core-elements-for-online-business-stress-testing)
|
||||
- [ListParts - Amazon S3 API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListParts.html)
|
||||
- [微信支付:JSAPI 支付开发指引](https://pay.weixin.qq.com/doc/v3/merchant/4012791870)
|
||||
- [微信支付:JSAPI 支付常见问题](https://pay.weixin.qq.com/doc/v3/merchant/4012791869)
|
||||
- [微信支付:支付成功回调通知](https://pay.weixin.qq.com/doc/v3/merchant/4012791861)
|
||||
- [微信支付:支付回调和查单实现指引](https://pay.weixin.qq.com/doc/v3/merchant/4012075249)
|
||||
- [微信支付:订单退款开发指引](https://pay.weixin.qq.com/doc/v3/merchant/4013071031)
|
||||
- [微信支付 V2:关闭订单](https://pay.weixin.qq.com/doc/v2/merchant/4011935216)
|
||||
- [微信支付:商家转账](https://pay.weixin.qq.com/doc/v3/merchant/4012716434)
|
||||
- [支付宝:异步通知说明](https://opendocs.alipay.com/open/00dn78)
|
||||
- [How to Minimize and Handle Deadlocks(MySQL 8.0 参考手册)](https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks-handling.html)
|
||||
- [Uploading objects in Amazon S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/upload-objects.html)
|
||||
- [MinIO 源码 cmd/api-errors.go、cmd/object-api-errors.go](https://github.com/minio/minio/tree/master/cmd)
|
||||
- [Nginx ngx_http_core_module:client_max_body_size](https://nginx.org/en/docs/http/ngx_http_core_module.html)
|
||||
|
||||
Reference in New Issue
Block a user