《双花防护下的高并发记账:协程事务 + io_uring 持久化日志的一致性保证》

📅 2026/6/20 17:48:06 👤 编程新知 🏷️ 技术资讯
《双花防护下的高并发记账:协程事务 + io_uring 持久化日志的一致性保证》 双花防护下的高并发记账讲透。这一篇是前几篇的收口之作——前面解决了写得快、不丢、可审计、可恢复,这一篇解决最 难⎿ Tip:Use/btw to ask a quick side question without interrupting Claudes current work 的那个:高并发下,同一笔钱绝不能被花两次。 我先把双花到底怎么发生的讲清楚,再给出协程事务io_uring ──持久化日志的完整一致性方案。─────────────────────────────────────────────────────────────────────────────────────────---─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 一、先搞懂:双花(double-spend)到底是什么,怎么发生的 大白话:双花同一笔钱被花了两次。 账户里只有100块,结果两笔80块的支出都成功了,凭空多花了60。在高并发记账系统里,它主要有三种发生方式:1.1并发竞争(最经典,Race Condition)两个协程同时给账户 A 转账,都先读余额、再扣减:账户A余额100协程1:读余额100→ 判断10080✓ → 扣减,写回20协程2:读余额100→ 判断10080✓ → 扣减,写回20↑两个都读到100,都以为够!结果:扣了两笔80(共160),但余额只显示20→凭空多花80根因:读余额→判断→扣减这三步不是原子的,中间被插队了。1.2重复提交/重放(Idempotency 问题)客户端网络超时重试,同一笔转账请求发了两次;或者攻击者故意重放。系统当成两笔处理 →扣两次。1.3崩溃恢复时重复应用 系统崩了,恢复时重放日志,如果没做幂等,同一笔可能被应用两次。 这一篇的目标:用三道防线把这三种全堵死,且在高并发下不牺牲太多性能。---二、三道防线(整体方案)第1道:幂等键(Idempotency Key)→挡住重复提交/重放第2道:按账户串行化(per-account)→挡住并发竞争,且只锁单个账户不锁全局 第3道:WAL先持久化再确认幂等重放 →挡住崩溃重复应用,保证落盘前不认账 核心设计思想(大白话):-不要用一把大锁锁住整个账本——那样并发直接归零。要按账户分片,转账只锁涉及的那一两个账户,不相干的账户照样并行。-检查余额 扣减 写日志必须是一个原子事务,中间不能被插队。用协程把它串起来。-先把交易写进 WAL 并 fsync 落盘,才更新内存余额、才回复成功。这样崩溃了也能恢复成一致状态。---三、完整实现 第1步 账户状态与幂等表(内存持久化)→第2步 按账户串行化引擎(防并发竞争的核心)→第3步 协程事务:check→WAL→appl原子提交 →第4步 双花压测(并发猛打同一账户,验证一分不多)→第5步 崩溃恢复幂等重放---第1步:账户状态与幂等表 大白话:内存里维护每个账户的余额(快),同时每笔交易都靠一个全局唯一的幂等键去重。余额变更前先查幂等表,见过的请求直接返 回上次结果,绝不重复执行。?php// 账户状态 幂等表use Swoole\Atomic;class AccountStore{// 账户余额表:account balance(分为单位,用整数避免浮点误差!)private array $balances[];// 幂等表:idempotencyKey 上次执行结果(防重复提交/重放)private array $idempotent[];// 账户版本号:用于乐观并发控制(每次变更1)private array $versions[];public functiongetBalance(string $acct):int{return$this-balances[$acct]??0;}public functiongetVersion(string $acct):int{return$this-versions[$acct]??0;}/** 初始化/充值账户(也应走事务,这里简化) */public functioncredit(string $acct,int$amount):void{$this-balances[$acct]($this-balances[$acct]??0)$amount;$this-versions[$acct]($this-versions[$acct]??0)1;}public functiondebit(string $acct,int$amount):void{$this-balances[$acct]-$amount;$this-versions[$acct]($this-versions[$acct]??0)1;}/** 幂等检查:这个key见过吗?见过就返回上次结果 */public functionseen(string $key):?array{return$this-idempotent[$key]??null;}public functionremember(string $key,array $result):void{$this-idempotent[$key]$result;}}踩坑提醒:金额一律用整数(分/厘),绝不用float。0.10.2!0.3,浮点误差在记账系统是致命的。---第2步:按账户串行化引擎(防并发竞争的核心)大白话:这是整篇的关键。要保证同一个账户的操作必须排队执行(串行),但不同账户可以并行。做法——给每个账户一把协程锁(用 Channel 当锁),转账时只锁涉及的账户。?php// 按账户串行化 ——同账户串行,跨账户并行,且防死锁use Swoole\Coroutine;use Swoole\Coroutine\Channel;class AccountLockManager{// account 一个容量1的Channel,当作该账户的协程锁private array $locks[];private functionlockFor(string $acct):Channel{if(!isset($this-locks[$acct])){$chnewChannel(1);$ch-push(true);// 放一个令牌:有令牌锁空闲$this-locks[$acct]$ch;}return$this-locks[$acct];}/** * 锁住多个账户执行临界区。 * 关键防死锁:对账户名排序后按固定顺序加锁(避免A等B、B等A的死锁) */public functionwithLocked(array $accounts,callable $fn){// 去重 排序 →所有协程都按同一顺序拿锁,杜绝死锁$accountsarray_unique($accounts);sort($accounts);$acquired[];try{foreach($accounts as $acct){// pop令牌 拿到锁;拿不到就在这挂起(协程yield,线程不阻塞)$this-lockFor($acct)-pop();$acquired[]$acct;}// 全部锁到手,执行临界区(此时这些账户被独占,无人能插队)return$fn();}finally{// 无论成功失败,逆序释放锁(把令牌还回去)foreach(array_reverse($acquired)as $acct){$this-lockFor($acct)-push(true);}}}}为什么这样能防双花(大白话):转账涉及账户 A,加锁后,任何其他想动 A 的协程都得在pop()那里排队等着。所以读 A 余额→判断→扣A整个过程不会被插队,1.1节的竞争消失了。而动账户 C、D 的转账完全不受影响,照样并行——这就是按账户分片锁的威力。 为什么排序加锁防死锁:如果协程1锁A再锁B,协程2锁B再锁A,就会互相等死。所有协程都先排序、按字典序加锁(永远先A后B),就不可能出现环形等待。---第3步:协程事务 ——check→WAL→appl原子提交(收口)大白话:把幂等检查 →锁账户 →验余额(防双花)→写WAL落盘 →改内存余额 →记幂等缝成一个原子事务。核心纪律:WAL 落盘成功之前,绝不更新余额、绝不回复成功。?php// 双花防护的转账事务引擎use Swoole\Coroutine;use Swoole\Coroutine\Channel;use Swoole\Coroutine\WaitGroup;class TransferEngine{public function__construct(private AccountStore $store,private AccountLockManager $locks,private AppendOnlyLedger $wal// 复用上一篇的组提交WAL引擎){}/** * 转账:从from扣amount给to。返回结果数组。 * param string $idemKey 幂等键(客户端生成的唯一ID,如UUID) */public functiontransfer(string $idemKey,string $from,string $to,int$amount):array{// 防线1:幂等检查(挡重复提交/重放) // 注意:幂等检查也要在锁内做才严谨,这里先做快速短路$seen$this-store-seen($idemKey);if($seen!null){return$seen[idempotent_hittrue];// 见过了,直接返回上次结果}if($amount0){return[okfalse,erramount must be positive];}// 防线2:锁住from和to两个账户(同账户串行) return$this-locks-withLocked([$from,$to],function()use($idemKey,$from,$to,$amount){// 进锁后再查一次幂等(双重检查,防两个相同key同时进来)$seen$this-store-seen($idemKey);if($seen!null){return$seen[idempotent_hittrue];}// 核心:验余额(防双花)。此刻独占账户,读到的余额绝对准 $balance$this-store-getBalance($from);if($balance$amount){$result[okfalse,errinsufficient funds,balance$balance];$this-store-remember($idemKey,$result);// 失败也记幂等,防重试再算return$result;}// 防线3:先写WAL并落盘,成功了才改余额 // 这是一致性的命门:数据落盘 余额变更 回复成功,顺序不能乱$entry[typeTRANSFER,idem$idemKey,from$from,to$to,amount$amount,from_ver$this-store-getVersion($from),// 记版本,便于审计/重放校验tshrtime(true),];// append 内部:组提交 fdatasync,返回时数据已真正落盘$walResult$this-wal-append($entry);if(!$walResult[ok]){// WAL没落盘 →整笔失败,余额一分不动(保证一致性)return[okfalse,errwal persist failed];}// 落盘成功,现在才更新内存余额(此前的崩溃都不会丢/错) $this-store-debit($from,$amount);$this-store-credit($to,$amount);$result[oktrue,lsn$walResult[lsn],from_balance$this-store-getBalance($from),];// 记幂等:同一个key再来直接返回这个结果$this-store-remember($idemKey,$result);return$result;});}}这段的执行顺序就是一致性保证(背下来):锁账户 →查幂等 →验余额(够不够)→写WALfsync落盘 →改内存余额 →记幂等 →解锁 ↑挡重放 ↑挡双花 ↑挡丢失/崩溃 ↑此前崩溃都安全 为什么崩溃也一致(大白话):-如果崩在WAL 落盘前:内存余额还没动,WAL 里也没这笔,等于没发生过,客户端没收到成功,会重试 →安全。-如果崩在WAL 落盘后、改余额前:WAL 里有这笔。重启重放 WAL 会把余额改对 →最终一致,且靠幂等键不会重复。-任何时刻WAL 里的内容才是唯一真相,内存只是它的缓存。---第4步:双花压测(用最狠的方式验证——并发猛打同一账户)大白话:开一个只有100块的账户,放出1000个协程同时去取1块,理论上最多只能成功100笔。如果防护有效,成功数必须恰好100,余额恰好0,绝不能出现成功101笔或余额变负。?php// 双花压测:1000协程抢一个只够100次的账户use Swoole\Coroutine;use Swoole\Coroutine\WaitGroup;use Swoole\Atomic;Coroutine\run(function(){$storenewAccountStore();$locksnewAccountLockManager();$walnewAppendOnlyLedger(/data/ledger/ledger.dat);// 上一篇的引擎$enginenewTransferEngine($store,$locks,$wal);// 账户金库充值100块(分为单位,这里直接用整数100)$store-credit(vault,100);$store-credit(sink,0);$successnewAtomic(0);$failnewAtomic(0);$wgnewWaitGroup();// 1000个协程同时抢,每个想从vault取1块for($i0;$i1000;$i){$wg-add();Coroutine::create(function()use($engine,$i,$success,$fail,$wg){// 每笔一个唯一幂等键$r$engine-transfer(txn-$i,vault,sink,1);$r[ok]?$success-add(1):$fail-add(1);$wg-done();});}$wg-wait();echo成功笔数:.$success-get(). (必须100)\n;echo失败笔数:.$fail-get(). (必须900,因余额不足)\n;echovault余额:.$store-getBalance(vault). (必须0,绝不能为负)\n;echosink余额 :.$store-getBalance(sink). (必须100)\n;// 一致性总检查:总额守恒(钱不会凭空多/少)$total$store-getBalance(vault)$store-getBalance(sink);echo总额守恒:.($total100?✅ 通过(100):❌ 失败!出现双花!).\n;$wal-close();});判定标准(大白话):-成功必须恰好100笔,失败900笔。-vault 必须恰好0,绝不能为负(为负就是双花了)。-总额守恒:vaultsink 永远100。多一分少一分都是 bug。 再加一个幂等测试:同一个 key 提交两次,第二次必须返回 idempotent_hittrue 且不重复扣钱。?php// 幂等测试:同一笔重复提交不重复扣$r1$engine-transfer(same-key,vault,sink,10);$r2$engine-transfer(same-key,vault,sink,10);// 重放!echo $r1[ok]?第1次:扣款成功\n:第1次失败\n;echoisset($r2[idempotent_hit])?第2次:命中幂等,未重复扣 ✅\n:第2次:危险,重复扣了 ❌\n;---第5步:崩溃恢复幂等重放(保证恢复也不双花)大白话:重启后,从 WAL 重放重建余额。靠 LSN 和幂等键,保证每笔只应用一次,绝不因重放而双花。?php// 崩溃恢复:从WAL重放,幂等重建账户状态class TransferRecovery{public functionrebuild(string $walPath,AccountStore $store):array{[$entries,$count]$this-readAll($walPath);$applied[];// 已应用的幂等键,防重放重复$n0;foreach($entries as $e){// 幂等:同一笔(同idem key)只应用一次if(isset($applied[$e[idem]]))continue;if($e[type]TRANSFER){// 重放时不再校验余额(WAL里的都是当时已通过校验、已落盘的真实交易)$store-debit($e[from],$e[amount]);$store-credit($e[to],$e[amount]);}$applied[$e[idem]]true;$store-remember($e[idem],[oktrue,replayedtrue]);$n;}return[$store,$n];}private functionreadAll(string $path):array{$fpfopen($path,rb);$sizefstat($fp)[size];$data$size0?fread($fp,$size):;fclose($fp);$entries[];$offset0;while(true){$recLedgerRecord::decode($data,$offset);// 上一篇的解码器if($recnull)break;// CRC不过/半截 →安全停止$entries[]$rec[txn];$offset$rec[next];}return[$entries,count($entries)];}}// 用法:重启时先恢复,再对外服务Swoole\Coroutine\run(function(){$storenewAccountStore();[$store,$n](newTransferRecovery())-rebuild(/data/ledger/ledger.dat,$store);echo崩溃恢复:重放 {$n} 笔交易,账户状态已重建\n;echovault余额:.$store-getBalance(vault).\n;// 之后用这个 $store 继续提供服务,状态和崩溃前完全一致});为什么恢复不双花(大白话):WAL 里记录的每笔都带唯一幂等键,重放时用 $applied 表去重,同一笔绝不应用两次。而且重放不重新校验余额——因为能进WAL 的都是当时已经通过余额校验、已经落盘的既成事实,直接照搬即可,结果必然和崩溃前一致。---四、一致性保证的完整逻辑链(一图背下)高并发请求涌入 │ ┌────────────────┼────────────────┐ 防线1:幂等键查重 ──→见过?──是──→返回上次结果(不重复执行)│ 否 防线2:按账户排序加锁(同账户串行,跨账户并行,防死锁)│ ├─ 锁内验余额:够不够?──不够──→失败(记幂等)│ 够 防线3:写WALfdatasync落盘 ──失败──→整笔回滚,余额不动 │ 落盘成功 ├─ 更新内存余额(此前任何崩溃都安全)├─ 记幂等键 └─ 解锁,回复成功 崩溃重启 ──→重放WAL(幂等去重)──→状态与崩溃前一致 三句话总结一致性:1.WAL 落盘是唯一真相,内存余额只是它的缓存,落盘前不认账。2.同账户串行让查余额→扣减原子化,杜绝并发双花。3.幂等键让重复提交、重放、崩溃恢复都不会重复扣钱。---五、避坑 Top101.金额用float→浮点误差,对账永远差几分。一律用整数(分/厘)。2.读余额→扣减不加锁→经典双花。必须锁住账户让这两步原子。3.用一把全局大锁 →并发归零。要按账户分片锁,只锁涉及的账户。4.加锁不排序 →A等B、B等A 死锁。所有协程按账户名排序后固定顺序加锁。5.先改余额后写日志 →崩在中间,余额变了日志没记,对不上账。必须先WAL落盘后改余额。6.没有幂等键 →客户端重试/重放导致重复扣款。每笔带唯一key,执行前查重。7.失败的交易不记幂等 →重试时反复执行余额校验,边界情况出错。成功失败都记。8.WAL fsync 返回值不检查 →没落盘当成功,崩溃后这笔丢失。必须判 fdatasync 返回。9.恢复重放不去重 →重放导致重复应用,余额翻倍(恢复时的双花)。用幂等键去重。10.幂等表无限增长 →内存爆。要定期/按时间窗清理老的幂等键(配合对账后清)。---六、最优工具/库清单(全自带/成熟,不自研)┌───────────────┬───────────────────────────────────────┬────────────────────────────────────┐ │ 任务 │ 最优选择 │ 理由 │ ├───────────────┼───────────────────────────────────────┼────────────────────────────────────┤ │ 协程并发框架 │ Swoole6│ 协程事务、Channel、Atomic 一应俱全 │ ├───────────────┼───────────────────────────────────────┼────────────────────────────────────┤ │ 按账户锁 │ Swoole\Coroutine\Channel(容量1)当锁 │ 协程级,挂起不阻塞线程 │ ├───────────────┼───────────────────────────────────────┼────────────────────────────────────┤ │ 持久化日志 │ io_uringWAL(组提交,上一篇)│ 落盘快且不丢 │ ├───────────────┼───────────────────────────────────────┼────────────────────────────────────┤ │ 强制落盘 │fdatasync()│ 持久性屏障,预分配后比 fsync 快 │ ├───────────────┼───────────────────────────────────────┼────────────────────────────────────┤ │ 原子计数/版本 │ Swoole\Atomic │ 跨协程原子操作 │ ├───────────────┼───────────────────────────────────────┼────────────────────────────────────┤ │ 并发等待 │ Swoole\Coroutine\WaitGroup │ 自带 │ ├───────────────┼───────────────────────────────────────┼────────────────────────────────────┤ │ 幂等键 │ UUID/客户端唯一ID内存/持久幂等表 │ 防重放标准做法 │ ├───────────────┼───────────────────────────────────────┼────────────────────────────────────┤ │ 金额表示 │ 整数(分/厘)│ 杜绝浮点误差 │ ├───────────────┼───────────────────────────────────────┼────────────────────────────────────┤ │ 完整性校验 │ crc32LSN(上一篇编码)│ 崩溃半截检测重放去重 │ └───────────────┴───────────────────────────────────────┴────────────────────────────────────┘---这一系列(信创迁移 →行情分发 →协程锁 →io_uring 落盘 →Append-Only 账本 → 双花防护)到这里形成了一套完整的国产化高并发金融系统知识链。 需要我继续往下钻的话,告诉我:-要不要把这套扩展成分布式?单机串行锁不够时,要上分布式锁(用达梦/金仓的 SELECT FOR UPDATE 或 Redis)两阶段提交,我给完整的分布式双花防护方案。-账户状态要不要落地到国产数据库(达梦/金仓)而不是纯内存?我给DB 行锁 WAL双保险的整合版,真正生产可用。-要不要加对账(reconciliation)模块?定期用 WAL 全量重算余额,和内存/DB 对比,发现任何不一致立即告警——金融系统的最后一道防线。 把方向告诉我,我直接给生产级的完整代码。