<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Spring Boot on Renxin's Blog</title><link>https://zh.renxinblog.cn/tags/spring-boot/</link><description>Recent content in Spring Boot on Renxin's Blog</description><generator>Hugo -- gohugo.io</generator><language>zh</language><lastBuildDate>Sun, 15 Feb 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://zh.renxinblog.cn/tags/spring-boot/index.xml" rel="self" type="application/rss+xml"/><item><title>第三话｜跨行清算对账：跟你跑通一个对账流水线，亲手拆解分布式一致性的工程骨架</title><link>https://zh.renxinblog.cn/post/gyd-c03-reconciliation/</link><pubDate>Sun, 15 Feb 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/gyd-c03-reconciliation/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/gyd-c03-reconciliation-cover.png" alt="Featured image of post 第三话｜跨行清算对账：跟你跑通一个对账流水线，亲手拆解分布式一致性的工程骨架" /&gt;&lt;p&gt;A 从工行转 10,000 元给 B 的建行账户。工行 App 显示扣款成功，建行的到账短信也到了，两边客户看到的都是「成功」。&lt;/p&gt;
&lt;p&gt;第二天日终对账跑完，报表上多出一行差异：建行侧这笔交易有两条分录（账本上的记账记录），B 的账户多出一万块。钱只转了一次。那条多出来的分录是哪来的？谁负责把它消掉？能自动消掉吗？&lt;/p&gt;
&lt;p&gt;这三个问题，对账系统都得回答。而回答之前，得先知道这笔转账在哪几个地方各记了什么——它不是一个系统写一行数据，是三个互不信任的系统各写各的账。&lt;/p&gt;
&lt;p&gt;GYD 这个系列讲分布式系统里各种解决方案的实现方式。上一篇讲了消息队列的可靠投递——一条消息从发出到被处理，中间可能死在三段里。当消息可靠地到达了，新的问题浮现：两边的账还是可能对不上，你怎么发现、怎么分级处理、怎么修正？&lt;/p&gt;
&lt;p&gt;对账这件事，本质上不是在检查「账有没有记错」——它是在问一个更根本的问题：&lt;strong&gt;在没有全局一致性的分布式环境里，你用什么手段去证明系统的状态是对的？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;建一个最小可行的对账流水线：两个银行服务、各自的借贷账本、一个模拟结算服务、一个日终对账 Job。跑起来，亲手看到对账怎么发现差异、怎么分级决策、怎么追加式更正。不追求完整银行核心系统——那太大了——而是聚焦对账最核心的口径、匹配和分级处理。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="一一笔跨行转账动了三个账本"&gt;一、一笔跨行转账，动了三个账本
&lt;/h2&gt;&lt;p&gt;回到那笔 10,000 元。表面上看，就是工行的一个账户余额减了，建行的一个账户余额加了。但这个简化版本漏掉了最关键的一环：&lt;strong&gt;银行间的结算&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;先说清一件容易被跳过的事：工行不会用运钞车把一万块现金送到建行。银行之间的「钱」，是它们在央行开的账户里的数字。每家商业银行都在央行有一个&lt;strong&gt;结算账户&lt;/strong&gt;——好比公司里每个部门都在财务总部存了一笔备用金，跨部门的结算由总部在这个池子里挪数字。&lt;/p&gt;
&lt;p&gt;所以一笔跨行转账，实际有&lt;strong&gt;三个记账主体&lt;/strong&gt;，各自维护一个独立数据库，互相看不到对方的账：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;记账主体&lt;/th&gt;
					&lt;th&gt;账本上记的是什么&lt;/th&gt;
					&lt;th&gt;谁维护&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;工行核心系统&lt;/td&gt;
					&lt;td&gt;A 的客户存款 + 工行存放央行的钱&lt;/td&gt;
					&lt;td&gt;工行&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;央行支付系统&lt;/td&gt;
					&lt;td&gt;工行结算账户 + 建行结算账户&lt;/td&gt;
					&lt;td&gt;央行&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;建行核心系统&lt;/td&gt;
					&lt;td&gt;建行存放央行的钱 + B 的客户存款&lt;/td&gt;
					&lt;td&gt;建行&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id="三幕记账"&gt;三幕记账
&lt;/h3&gt;&lt;p&gt;时间线上分三幕，每一幕各写一组分录。分录（journal entry）就是账本上的一行记账记录，它的约束第二节细讲。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一幕：A 点转账，工行记账。&lt;/strong&gt; 工行校验 A 余额充足，在自己的账本上写入两条：A 的客户存款减少一万，工行存放央行的钱减少一万。此时钱还没易手，只是工行单方面记下了「我要付一万出去」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二幕：工行把支付指令发给央行，央行记账。&lt;/strong&gt; 央行检查工行结算账户余额够不够，够则同时改两户：工行的结算账户减一万，建行的结算账户加一万。&lt;strong&gt;这一步完成，银行间资金交割就「最终」了&lt;/strong&gt;——在央行的制度里这叫结算最终性，记入央行账户后不可撤销。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三幕：央行通知建行，建行记账。&lt;/strong&gt; 建行收到入账通知，确认 B 账户状态正常，在自己的账本上写入两条：建行存放央行的钱增加一万，B 的客户存款增加一万。之后建行回执、工行给 A 弹「转账成功」——那是通知，不是记账。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
sequenceDiagram
 autonumber
 participant A as A（工行客户）
 participant ICBC as 工行核心系统
 participant PBC as 央行支付系统
 participant CCB as 建行核心系统
 participant B as B（建行客户）

 A-&gt;&gt;ICBC: 发起转账 10,000 元
 ICBC-&gt;&gt;ICBC: 记账：客户存款-A 减 / 存放央行 减
 ICBC-&gt;&gt;PBC: 支付指令
 PBC-&gt;&gt;PBC: 记账：结算存款-工行 减 / 结算存款-建行 加
 Note over PBC: 资金真正易手，结算最终性成立
 PBC-&gt;&gt;CCB: 入账通知
 CCB-&gt;&gt;CCB: 记账：存放央行 加 / 客户存款-B 加
 CCB--&gt;&gt;B: 到账短信
 ICBC--&gt;&gt;A: 转账成功&lt;/pre&gt;&lt;p&gt;三幕走完，四个账户余额发生了变化：A 的存款 −1 万、工行在央行的结算户 −1 万、建行在央行的结算户 +1 万、B 的存款 +1 万。钱没有消失也没有多出，只是欠条链条换了人。&lt;/p&gt;
&lt;h3 id="同一个结算账户在两个账本里性质相反"&gt;同一个结算账户，在两个账本里性质相反
&lt;/h3&gt;&lt;p&gt;这里有一处最容易绕晕，说透了后面全通：&lt;strong&gt;工行在央行的那个结算账户，在工行账本上是资产，在央行账本上是负债。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;就像你的工资卡余额——对你自己记「别人欠我一万」，对银行记「我欠客户一万」。同一笔债，两边各记各的，谁也没错。所以第二幕里，央行记的是「结算存款—工行」减少，因为那是央行欠工行的钱变少了；而第一幕里，工行记的是「存放央行款项」减少，因为那是工行存在别人那里的钱变少了。&lt;strong&gt;两笔指的是同一件事，但记在两个不同的数据库里，用两个方向相反的科目名。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;对账要跨的就是这种缝：三本账各记各的，同一个事实在不同账本里长得完全不一样。&lt;/p&gt;
&lt;h3 id="为什么裁判只能是央行"&gt;为什么裁判只能是央行
&lt;/h3&gt;&lt;p&gt;既然工行和建行各记各的账，为什么不能两边互相对一下就完事？&lt;/p&gt;
&lt;p&gt;假设只让两家互对：工行说「我转了」，建行说「我没收到」，信谁？两家都是这笔交易的利益相关方，漏记、错记的一方有动机把责任推给对方。更糟的情况是两边&lt;strong&gt;都&lt;/strong&gt;记错了——比如入账通知丢失导致两边都没入账，互对反而「对平了」，两家错得整整齐齐，谁也不知道出了问题。&lt;/p&gt;
&lt;p&gt;央行不一样：它在这笔交易里没有利益瓜葛，而且是&lt;strong&gt;资金真正动过的那个地方&lt;/strong&gt;。第一幕和第三幕都只是各家自己账本上的数字变化，第二幕才是钱真的易手。既然「钱到底转没转成」这个动作只发生在央行账上，这个问题的裁判也只能是央行账。&lt;/p&gt;
&lt;p&gt;这就是「两方互对只能发现不一致，三方核对才能判定哪边不一致」的由来：工行建行互对，发现差一万，吵不清谁错；拿出央行记录一看「总部说转了一万」，立刻知道是哪家的小账本没记上。&lt;/p&gt;
&lt;p&gt;这条判断抽掉金融外壳也成立——&lt;strong&gt;谁是那个动作真正发生的地方，谁的数据就是事实；两边平票时，找一个改它记录对自己没好处的第三方。&lt;/strong&gt; 支付公司和微信对账以商户平台账单为准，电商和物流对账以仓储出库记录为准，都是同一个道理。&lt;/p&gt;
&lt;h3 id="三幕链条会断在哪里"&gt;三幕链条会断在哪里
&lt;/h3&gt;&lt;p&gt;三幕跨越三个数据库、靠异步消息传递，没有任何全局事务能保证三步原子完成。断点至少有三个：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;断在哪&lt;/th&gt;
					&lt;th&gt;后果&lt;/th&gt;
					&lt;th&gt;对账看到的现象&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;第一幕成功、指令没发出去&lt;/td&gt;
					&lt;td&gt;工行扣了 A 的钱，央行和建行都没动静&lt;/td&gt;
					&lt;td&gt;付款行有分录、结算侧无记录&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;第二幕成功、入账通知丢了&lt;/td&gt;
					&lt;td&gt;钱已到建行在央行的户头，但 B 没到账&lt;/td&gt;
					&lt;td&gt;结算已确认、收款行漏记&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;通知重复投递、消费端没做好幂等&lt;/td&gt;
					&lt;td&gt;B 到账两万&lt;/td&gt;
					&lt;td&gt;同一笔交易在收款行有重复分录&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;开篇那个场景就是第三种。记住这三种断裂方式，第四节的差异分类表就不是凭空冒出来的分类，而是这三个断点的直接映射。&lt;/p&gt;
&lt;h3 id="demo-的架构"&gt;demo 的架构
&lt;/h3&gt;&lt;p&gt;三幕在 demo 里的落点：&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
flowchart LR
 ICBC["工行服务&lt;br/&gt;客户存款账本"]
 CCB["建行服务&lt;br/&gt;客户存款账本"]
 SETTLE["模拟结算服务&lt;br/&gt;清算结果记录"]
 MQ["RabbitMQ&lt;br/&gt;清算通道"]
 RECON["对账 Job&lt;br/&gt;日终逐笔匹配"]
 
 SETTLE --&gt;|"分别投递"| MQ
 MQ --&gt;|"消费记账"| ICBC
 MQ --&gt;|"消费记账"| CCB
 RECON --&gt;|"REST 拉取当日数据"| ICBC
 RECON --&gt;|"REST 拉取当日数据"| CCB
 SETTLE -.-&gt;|"对账 Job 同进程&lt;br/&gt;直接读本地库"| RECON&lt;/pre&gt;&lt;p&gt;模拟结算服务充当那个「央行的角色」，产生每笔转账的清算结果。RabbitMQ 只是传递这些指令的通道——它不代替结算服务的权威性。对账 Job 从两家银行各拉一份数据，再加上自己进程内的结算记录，凑齐三方才能进入真正的匹配逻辑。&lt;/p&gt;
&lt;p&gt;有一处和真实世界的差异要说清楚：现实中是「工行&lt;strong&gt;主动向&lt;/strong&gt;央行发指令，央行结算后&lt;strong&gt;通知&lt;/strong&gt;建行」，方向和顺序都固定；demo 里是结算服务生成结果后通过 RabbitMQ &lt;strong&gt;分别投递&lt;/strong&gt;给两家银行记账，两家都是被动消费。方向不同，但「三方各记各账、靠同一个交易流水号关联」的骨架一致。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="二单方账本的约束借贷记账法的边界"&gt;二、单方账本的约束：借贷记账法的边界
&lt;/h2&gt;&lt;p&gt;在讲跨行对账之前，先要弄清楚一件事：&lt;strong&gt;一家银行自己的账本，凭什么说自己是「自洽」的？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;答案是一套五百多年前发明的记账规则：借贷记账法（Double-Entry Bookkeeping）。&lt;/p&gt;
&lt;h3 id="先把三个词落地"&gt;先把三个词落地
&lt;/h3&gt;&lt;p&gt;上一节说「工行写入两条」，那两条长什么样？要看懂它，得先落地三个词。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;科目&lt;/strong&gt;：账本上的分类抽屉。银行账本上不写「张三」「李四」，而写「客户存款」「存放央行款项」这样的类别，每一类是一个科目，下面再挂具体账户。第一节里工行记的那两条，科目分别是「客户存款」和「存放央行款项」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;资产和负债&lt;/strong&gt;：「存放央行款项」是资产——钱存在别人那里，是别人欠银行的。「客户存款」是负债——客户存进来的钱是银行欠客户的，客户随时可以取走。你存 1 万，是银行欠你 1 万，这笔钱在银行账上从来不是「银行的钱」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;借和贷&lt;/strong&gt;：跟借钱、贷款没有任何关系。它们是会计里的两个方向符号，相当于左和右，五百年前意大利商人记账时定下的习惯，沿用至今。方向怎么定，规则只有两条：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;&lt;/th&gt;
					&lt;th&gt;增加记&lt;/th&gt;
					&lt;th&gt;减少记&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;资产&lt;/td&gt;
					&lt;td&gt;借&lt;/td&gt;
					&lt;td&gt;贷&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;负债&lt;/td&gt;
					&lt;td&gt;贷&lt;/td&gt;
					&lt;td&gt;借&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;有了这两条，第一幕那两条分录就能自己读通了：客户存款是负债，减少记&lt;strong&gt;借&lt;/strong&gt;；存放央行是资产，减少记&lt;strong&gt;贷&lt;/strong&gt;。第三幕反过来：存放央行是资产，增加记&lt;strong&gt;借&lt;/strong&gt;；客户存款是负债，增加记&lt;strong&gt;贷&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id="借贷记账法做了什么"&gt;借贷记账法做了什么
&lt;/h3&gt;&lt;p&gt;每笔账务交易，必须至少产生两条分录——一条记借方（Debit），一条记贷方（Credit），这两条的总金额相等、方向相反。&lt;/p&gt;
&lt;p&gt;转账那笔 10,000 元，在工行侧产生的分录是：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;方向&lt;/th&gt;
					&lt;th&gt;科目&lt;/th&gt;
					&lt;th&gt;金额&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;借&lt;/td&gt;
					&lt;td&gt;客户存款（负债减少）&lt;/td&gt;
					&lt;td&gt;¥10,000&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;贷&lt;/td&gt;
					&lt;td&gt;存放央行款项（资产减少）&lt;/td&gt;
					&lt;td&gt;¥10,000&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;两条分录的金额相等，借贷平衡。把工行所有账务分录的借方加总、贷方加总，两者必须相等——这就是「试算平衡」。&lt;/p&gt;
&lt;p&gt;为什么钱必须记成对？因为钱不会凭空出现——它一定是「从某处来、到某处去」。只记「A 少了一万」而不记「这一万去了哪」，账本就没交代这笔钱的去向。借贷成对，等于强制每一笔都写清来源和去向。&lt;/p&gt;
&lt;p&gt;这个约束的价值在于：&lt;strong&gt;任何一笔账务操作，要么借方和贷方一起写入（交易提交），要么一起不写（交易回滚）。&lt;/strong&gt; 数据库的原子提交保证了这一点。所以「借记了一笔但忘了记对应的贷方」在正常运行的系统中不会发生——要么都记，要么都不记。&lt;/p&gt;
&lt;p&gt;在 demo 里，这个约束落在 &lt;code&gt;LedgerService.postEntries&lt;/code&gt; 上——它接收一组分录，校验借贷平衡后在同一事务写入：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// bank/LedgerService.java（节选）&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;postEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 校验借贷平衡：借方合计必须等于贷方合计&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;debitSum&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDirection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Direction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DEBIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mapToLong&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;getAmountCents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;creditSum&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDirection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Direction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CREDIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mapToLong&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;getAmountCents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;debitSum&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;creditSum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;UnbalancedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;debitSum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;creditSum&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 借贷平衡 + 原子提交：所有分录在同一事务中写入&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;saveAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;注意两处和直觉不同的地方：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;金额用的是 &lt;code&gt;long&lt;/code&gt;（最小货币单位「分」），不是 &lt;code&gt;BigDecimal&lt;/code&gt; 也不是浮点。这不是「贬低 DECIMAL」——DECIMAL 本身是精确的定点数，银行系统里也广泛使用。demo 选 &lt;code&gt;long&lt;/code&gt; 分是因为它足够表达整数金额、且避免了跨语言序列化时定点数精度不一致的坑。这是&lt;strong&gt;工程取舍&lt;/strong&gt;，不是「浮点不安全所以只能选 long」。&lt;/li&gt;
&lt;li&gt;借贷平衡的校验在应用层完成，PostgreSQL 的普通 &lt;code&gt;CHECK&lt;/code&gt; 约束无法跨行校验「借方合计 == 贷方合计」——&lt;code&gt;CHECK&lt;/code&gt; 只能约束单行的取值范围。这是数据库约束的边界，需要应用层补上。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;借贷平衡 + 原子提交，保证了单方账本不会出现「凭空多了一笔钱或凭空少了一笔钱」。&lt;strong&gt;但它的保护范围到此为止。&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="借贷平衡不能发现的问题"&gt;借贷平衡不能发现的问题
&lt;/h3&gt;&lt;p&gt;试算平衡验证的是账务的内部自洽性，不是业务正确性。以下四类错误，账都是平的——借贷合计仍然相等：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;整笔漏记&lt;/strong&gt;：转账根本没入账。借方和贷方都没有，当然平衡。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;整笔重复入账&lt;/strong&gt;：同一笔转账被记了两次。第一次的借贷平衡，第二次的借贷也平衡，两次合起来仍然平衡。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;记错科目&lt;/strong&gt;：本该借记客户存款，却借记了另一个负债科目。借贷合计对得上，但客户的实际余额不对。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;两侧金额同时记错&lt;/strong&gt;：本来该记 10,000 元，两侧都记成了 1,000 元。借贷平衡，金额错误。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这四类正好覆盖第一节那三个断点：指令没发出去是整笔漏记，通知重复投递是整笔重复入账。&lt;strong&gt;它们全是异步链条的正常故障模式，而在单方账本上全都是「平的」——这就是为什么必须有跨方核对。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;试算平衡就像一道数学题的自检验算——能证明「我的计算过程没有低级算术错误」，但不能证明「我的算式列对了」。要证明「列对了」，需要和外部数据进行比对。这就是对账要解决的。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
flowchart TB
 subgraph PASS["「平了」但有问题"]
 direction LR
 P1["整笔漏记"]
 P2["整笔重复"]
 P3["记错科目"]
 P4["金额同时记错"]
 end
 subgraph FAIL["「不平」才是报警"]
 direction LR
 F1["单侧多记"]
 F2["金额单侧偏差"]
 end
 CHECK["借贷平衡校验"] --&gt; PASS
 CHECK --&gt; FAIL&lt;/pre&gt;&lt;p&gt;这张图的核心信息是：&lt;strong&gt;不要以为账平了就万事大吉&lt;/strong&gt;。平了只说明单方账簿没有算术错误。对账就是去验证「平了的那几行，是不是真的对应了一笔正确的交易」。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="三实时处理与周期核对可以并存"&gt;三、实时处理与周期核对可以并存
&lt;/h2&gt;&lt;p&gt;在讲对账逻辑之前，需要先澄清一个容易混淆的话题：&lt;strong&gt;什么时候对账？&lt;/strong&gt; 很多人把「清算的实时性」和「对账的时效」混为一谈。&lt;/p&gt;
&lt;p&gt;先用一个生活类比把三个词分开。你和同事这个月互相请客：算清楚「他欠我 80 块」是清算，他真的微信转你 80 是结算，月底翻聊天记录和账单确认「确实是 80，不是 180，也没重复算」是对账。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;维度&lt;/th&gt;
					&lt;th&gt;在做什么&lt;/th&gt;
					&lt;th&gt;时效&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;清算（Clearing）&lt;/td&gt;
					&lt;td&gt;计算各方的应收应付义务&lt;/td&gt;
					&lt;td&gt;HVPS（大额支付系统）逐笔实时，BEPS（小额支付系统）批量轧差&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;结算（Settlement）&lt;/td&gt;
					&lt;td&gt;通过资金账户完成义务清偿&lt;/td&gt;
					&lt;td&gt;大额实时，小额定时净额&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;对账（Reconciliation）&lt;/td&gt;
					&lt;td&gt;核对各方记录是否一致&lt;/td&gt;
					&lt;td&gt;可以是日终批次、小时批次或准实时&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;清算和结算发生在交易当时（第一节的第二幕），对账发生在事后。这个时间差就是对账存在的全部理由&lt;/strong&gt;——当时没人能保证三本账都写对了，只能事后摊开来核。&lt;/p&gt;
&lt;p&gt;大额支付系统（HVPS）逐笔实时全额清算，每笔大额转账完成时就已经决定了各家银行的应收应付义务，银行间的结算也是实时的。小额支付系统（BEPS）用批量轧差——工行今天欠建行 80、建行欠工行 50，不必转两次，轧一下只转净差额 30；把一段时间里所有小额交易的借方和贷方分别汇总，只结算这个净额。&lt;/p&gt;
&lt;p&gt;但不管清算和结算是实时的还是批量的，对账这件事都不一定跟它们同步。&lt;strong&gt;对账的时效是一个独立的工程选择&lt;/strong&gt;，取决于你愿意在多大延迟内发现差异、差异出现时系统里堆积了多少未核对数据。&lt;/p&gt;
&lt;p&gt;网联清算平台的做法很有代表性：它的交易是实时转接的，但对账按小时切分成多个批次，每个批次独立处理。好处是把一天的量打散到 24 小时里分别核对，避免了日终集中处理的性能洪峰，也让差异能在小时内被发现、而不是等到第二天。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;我们这个 demo 选择日终批次。&lt;/strong&gt; 不是因为「银行都是这样做的」，而是因为日终批次是理解对账口径最清晰的模型：定义一个业务日的截止点，拉取那一天的完整数据，逐笔匹配，对所有差异做出分类和处理。至于这个「日」是 24 小时、1 小时还是一刻钟，口径不变，只改调度频率。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="四简化对账流水线的实现"&gt;四、简化对账流水线的实现
&lt;/h2&gt;&lt;p&gt;好了，原理讲完，开始跑代码。整个 demo 的结构如下：&lt;/p&gt;
&lt;h3 id="服务清单"&gt;服务清单
&lt;/h3&gt;&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;服务&lt;/th&gt;
					&lt;th&gt;职责&lt;/th&gt;
					&lt;th&gt;对应第一节哪个角色&lt;/th&gt;
					&lt;th&gt;端口&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;gyd-c03-settlement&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;模拟结算服务：生成每笔转账的清算结果记录&lt;/td&gt;
					&lt;td&gt;央行（第二幕的记账者 + 权威记录源）&lt;/td&gt;
					&lt;td&gt;18080&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;gyd-c03-bank&lt;/code&gt;（bank-a profile）&lt;/td&gt;
					&lt;td&gt;付款行（工行）：维护客户存款账本，消费清算指令记账&lt;/td&gt;
					&lt;td&gt;工行（第一幕）&lt;/td&gt;
					&lt;td&gt;18081&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;gyd-c03-bank&lt;/code&gt;（bank-b profile）&lt;/td&gt;
					&lt;td&gt;收款行（建行）：维护客户存款账本，消费清算指令记账&lt;/td&gt;
					&lt;td&gt;建行（第三幕）&lt;/td&gt;
					&lt;td&gt;18082&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;ReconJob&lt;/code&gt;（结算服务内的组件）&lt;/td&gt;
					&lt;td&gt;对账任务：定时拉取三方数据，逐笔匹配，分级处理差异&lt;/td&gt;
					&lt;td&gt;事后核对，不属于三幕&lt;/td&gt;
					&lt;td&gt;随结算服务同进程（Spring Scheduler）&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;三个业务服务各自有独立的 PostgreSQL 数据库，中间通过 RabbitMQ 传递清算指令。对账 Job 随结算服务一起运行——它直接读本进程内的结算记录表（权威数据源），银行侧的分录则通过 REST API 拉取。&lt;/p&gt;
&lt;h3 id="数据库表设计"&gt;数据库表设计
&lt;/h3&gt;&lt;p&gt;每家银行的账本结构相同：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 账务分录表（append-only，已入账的行永不修改）
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;journal_entry&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BIGSERIAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;entry_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 业务流水号
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;trade_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 清算指令 ID（关联结算记录）
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CHECK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;DEBIT&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;CREDIT&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 金额，最小货币单位（分）
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;trade_time&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 业务发生时间
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;booked_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 记账时间（应用层 Instant.now() 写入）
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reversal_of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 若为冲正分录，指向被它抵销的那条原始分录（第六节细讲）
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_entry_trade&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;journal_entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trade_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_entry_booked&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;journal_entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;booked_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;模拟结算服务的数据库：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 清算结果表
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlement_record&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BIGSERIAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;trade_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 清算指令 ID
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;from_bank&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;to_bank&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settled_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 结算完成时间
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 结算状态，SETTLED = 已结算（应用层默认）
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;几个关键设计决策：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;金额用 &lt;code&gt;BIGINT&lt;/code&gt; 存分&lt;/strong&gt;（最小货币单位），不是浮点。浮点确实不能用于金额——多笔累加后的舍入误差会制造假差异。但 &lt;code&gt;DECIMAL&lt;/code&gt; 本身是精确的定点数，银行系统里也广泛使用，这里选 &lt;code&gt;BIGINT&lt;/code&gt; 分而不是 &lt;code&gt;DECIMAL&lt;/code&gt;，是为了让跨语言（Java ↔ SQL ↔ JSON）序列化时金额精度完全一致，减少一个出错面。这是取舍，不是说 &lt;code&gt;DECIMAL&lt;/code&gt; 不能用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;entry_id&lt;/code&gt; 是业务身份&lt;/strong&gt;。这是对账匹配的核心——同一个 &lt;code&gt;trade_id&lt;/code&gt; 在付款行和收款行各自产生分录，各自的 &lt;code&gt;entry_id&lt;/code&gt; 不同，但都关联到同一个 &lt;code&gt;trade_id&lt;/code&gt;。对账靠 &lt;code&gt;trade_id&lt;/code&gt; 匹配。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;reversal_of&lt;/code&gt; 链&lt;/strong&gt;。冲正分录指向被冲正的原始 &lt;code&gt;entry_id&lt;/code&gt;，形成完整的更正链路。对账的闭环验收靠这个字段追溯。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="对账-job-的核心逻辑"&gt;对账 Job 的核心逻辑
&lt;/h3&gt;&lt;p&gt;对账 Job 定时执行（demo 里用 &lt;code&gt;fixedDelay&lt;/code&gt;，方便跑起来立刻看到结果），拉取当日的完整数据：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// settlement/recon/ReconJob.java（节选，完整代码见仓库）&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedDelayString&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;${gyd.c03.recon.schedule-ms:60000}&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;initialDelayString&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;${gyd.c03.recon.initial-delay-ms:30000}&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;LocalDate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Instant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;atStartOfDay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ZoneOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toInstant&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Instant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;plusDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="na"&gt;atStartOfDay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ZoneOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toInstant&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 1. 拉取三方数据（银行通过 REST，结算记录直接查本地库）&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SettlementRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlements&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlementRepo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findSettledBetween&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bankAEntries&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;fetchBankEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bankAUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bankBEntries&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;fetchBankEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bankBUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 2. 以 trade_id 为键建立三方索引&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;aByTrade&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;groupByTrade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bankAEntries&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bByTrade&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;groupByTrade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bankBEntries&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SettlementRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlementMap&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Collectors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SettlementRecord&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;getTradeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;allTradeIds&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HashSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settlementMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;keySet&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;allTradeIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aByTrade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;keySet&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;allTradeIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bByTrade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;keySet&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 3. 逐笔匹配：结算侧无记录单独判，其余交给 checkBank 按科目核对&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ReconDiff&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;allTradeIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SettlementRecord&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlement&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlementMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;aEntries&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;aByTrade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOrDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bEntries&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bByTrade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOrDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settlement&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 银行有分录、结算侧查不到已结算记录 → 待查，不自动修正&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;aEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ReconDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ReconDiffType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NO_SETTLEMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ICBC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;entryIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aEntries&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;bEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ReconDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ReconDiffType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NO_SETTLEMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CCB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;entryIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bEntries&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;checkBank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aEntries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ICBC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;checkBank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bEntries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CCB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 4. 分级处理差异（processDiff 按类型决策）&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReconDiff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;processDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlementMap&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 5. 闭环验收&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;verifyClosedLoop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlementMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;checkBank&lt;/code&gt; 里的匹配规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;结算侧有没有记录，在主循环里单独判&lt;/strong&gt;，不在 &lt;code&gt;checkBank&lt;/code&gt; 里：&lt;code&gt;findSettledBetween&lt;/code&gt; 已经过滤掉非 &lt;code&gt;SETTLED&lt;/code&gt; 的记录，所以「银行有分录、结算侧查不到」直接标 &lt;code&gt;NO_SETTLEMENT&lt;/code&gt;（待查）。&lt;code&gt;checkBank&lt;/code&gt; 只负责单侧的逐科目核对。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;本行的角色从结算记录的 &lt;code&gt;fromBank&lt;/code&gt; / &lt;code&gt;toBank&lt;/code&gt; 推导&lt;/strong&gt;，不写死「工行一定是借方」——反向交易（建行付给工行）时，两个科目的方向会整体反过来。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;按科目逐个核对&lt;/strong&gt;，不是只看一个方向：本行这笔交易应该在「客户存款」和「存放央行款项」上各有一条净分录，预期方向由 &lt;code&gt;account.directionFor(movement)&lt;/code&gt; 推导。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;金额以结算记录为准&lt;/strong&gt;（权威数据源）。某科目上没有分录 → &lt;code&gt;MISSING&lt;/code&gt;；多于一条净分录 → &lt;code&gt;DUPLICATE&lt;/code&gt;；方向与预期不符 → &lt;code&gt;DIRECTION_MISMATCH&lt;/code&gt;；金额不符 → &lt;code&gt;AMOUNT_MISMATCH&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只比「净分录」&lt;/strong&gt;——已被冲正的原始分录不参与匹配，这一步在银行侧的查询里就完成了（&lt;code&gt;findNetByBookedAtBetween&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;金额不作为唯一匹配依据&lt;/strong&gt;——如果金额匹配但 &lt;code&gt;trade_id&lt;/code&gt; 不匹配，这不是同一笔交易。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;匹配结果的分类：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;结算记录&lt;/th&gt;
					&lt;th&gt;付款行分录&lt;/th&gt;
					&lt;th&gt;收款行分录&lt;/th&gt;
					&lt;th&gt;分类&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;有、状态=SETTLED&lt;/td&gt;
					&lt;td&gt;有、金额匹配&lt;/td&gt;
					&lt;td&gt;有、金额匹配&lt;/td&gt;
					&lt;td&gt;✅ 平&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;有、状态=SETTLED&lt;/td&gt;
					&lt;td&gt;无或金额不匹配&lt;/td&gt;
					&lt;td&gt;有&lt;/td&gt;
					&lt;td&gt;付款行漏记/金额偏差&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;有、状态=SETTLED&lt;/td&gt;
					&lt;td&gt;有&lt;/td&gt;
					&lt;td&gt;无或金额不匹配&lt;/td&gt;
					&lt;td&gt;收款行漏记/金额偏差&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;有、状态=SETTLED&lt;/td&gt;
					&lt;td&gt;方向与角色不符（金额对得上）&lt;/td&gt;
					&lt;td&gt;同左&lt;/td&gt;
					&lt;td&gt;方向记反（&lt;code&gt;DIRECTION_MISMATCH&lt;/code&gt;）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;有、状态=SETTLED&lt;/td&gt;
					&lt;td&gt;重复（同一 trade_id 多笔）&lt;/td&gt;
					&lt;td&gt;—&lt;/td&gt;
					&lt;td&gt;付款行重复入账&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;有、状态=SETTLED&lt;/td&gt;
					&lt;td&gt;—&lt;/td&gt;
					&lt;td&gt;重复（同一 trade_id 多笔）&lt;/td&gt;
					&lt;td&gt;收款行重复入账&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;无或状态≠SETTLED&lt;/td&gt;
					&lt;td&gt;—&lt;/td&gt;
					&lt;td&gt;—&lt;/td&gt;
					&lt;td&gt;待查：结算结果未知&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;有、状态=SETTLED&lt;/td&gt;
					&lt;td&gt;在途（trade_time 在截止点附近）&lt;/td&gt;
					&lt;td&gt;—&lt;/td&gt;
					&lt;td&gt;等待：数据可能尚未齐备&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;最后一行的「在途」指交易已经发起、但三幕还没走完的状态——比如第一幕工行记了账，第三幕的入账通知还在路上。这类记录不是错误，只是数据还没齐，下一批次再核。&lt;/p&gt;
&lt;p&gt;这里有一处代码与表格的落差，直说：&lt;code&gt;IN_FLIGHT&lt;/code&gt;（在途）作为差异类型和处理分支是存在的——&lt;code&gt;processDiff&lt;/code&gt; 对它只发 &lt;code&gt;[P2][在途]&lt;/code&gt; 告警、不动账，闭环验收也不把它算进失败——但 &lt;code&gt;checkBank&lt;/code&gt; 当前并不会主动产出这一类差异。它实际判定的是 &lt;code&gt;MISSING&lt;/code&gt;（漏记）、&lt;code&gt;DUPLICATE&lt;/code&gt;（重复入账）、&lt;code&gt;DIRECTION_MISMATCH&lt;/code&gt;（方向记反）、&lt;code&gt;AMOUNT_MISMATCH&lt;/code&gt;（金额不符）四类，主循环另判 &lt;code&gt;NO_SETTLEMENT&lt;/code&gt;（结算侧无记录）。主动判定在途需要一套「交易发起时间 vs 对账截止点」的口径规则，属于对账口径设计，留到讲口径的那一篇。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="五差异不是错误分级处理不是统一冲正"&gt;五、差异不是错误：分级处理，不是统一冲正
&lt;/h2&gt;&lt;p&gt;对账最容易被误解的地方是：&lt;strong&gt;发现了差异，不代表知道了问题。&lt;/strong&gt; 差异只是一个信号——两侧记录不一致。至于不一致的原因，是漏记、多记、记错、还是数据还没到，对账本身无法区分。&lt;/p&gt;
&lt;p&gt;回到开篇那个场景：报表上说建行侧多了一条分录。这条信息只告诉你「两边对不上」，它没告诉你该不该动、动哪一条、动了之后钱会不会退回去。&lt;/p&gt;
&lt;h3 id="分级决策模型"&gt;分级决策模型
&lt;/h3&gt;&lt;p&gt;对账 Job 对差异的处理不是「有一个差异就冲一笔」。它先判断这个差异的类型，再做对应动作：&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
flowchart TB
 DIFF["对账发现差异"]
 
 DIFF --&gt; Q1{"结算侧&lt;br/&gt;有对应记录？"}
 Q1 --&gt;|"无"| WAIT1["待查：标记为 pending&lt;br/&gt;下一批次重新核对"]
 Q1 --&gt;|"有"| Q2{"结算状态&lt;br/&gt;是 SETTLED？"}
 
 Q2 --&gt;|"否"| WAIT2["等待：交易仍在途&lt;br/&gt;下一批次重新核对"]
 Q2 --&gt;|"是"| Q3{"哪一侧&lt;br/&gt;不一致？"}
 
 Q3 --&gt;|"付款行缺分录"| ACT1["补记：根据结算记录&lt;br/&gt;补入借记分录"]
 Q3 --&gt;|"收款行缺分录"| ACT2["补记：根据结算记录&lt;br/&gt;补入贷记分录"]
 Q3 --&gt;|"金额不符"| ACT3["挂起：查证原指令&lt;br/&gt;人工裁决后修正"]
 Q3 --&gt;|"重复入账"| ACT4["挂起：无法判断哪组多余&lt;br/&gt;人工裁决后冲正"]&lt;/pre&gt;&lt;p&gt;这个决策树的每一层都依赖一个前提：&lt;strong&gt;结算记录是权威数据源。&lt;/strong&gt; 结算侧说这笔钱该付、该转，那它就是事实。银行分录如果缺了、多了、金额错了，以此为基准修正。&lt;/p&gt;
&lt;p&gt;但反过来，如果结算侧没有记录、或状态不是 &lt;code&gt;SETTLED&lt;/code&gt;，对账 Job &lt;strong&gt;不做任何自动修正&lt;/strong&gt;——它记一条 &lt;code&gt;NO_SETTLEMENT&lt;/code&gt; 差异，走 &lt;code&gt;[P1][待查]&lt;/code&gt; 告警，等下一个批次重新核对。权威源缺位时，宁可什么都不做也不猜。&lt;/p&gt;
&lt;h3 id="自动处理-vs-人工裁决"&gt;自动处理 vs 人工裁决
&lt;/h3&gt;&lt;p&gt;哪一类差异能自动处理？&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;漏记 + 结算已确认&lt;/strong&gt;：结算侧明确有一笔成功的清算结果，但某家银行没有对应的分录。这是最干净的情况——补记即可。补记的「正确动作」完全由结算记录决定（补哪一侧、补多少金额），没有歧义。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;哪些只能人工裁决？&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;重复入账&lt;/strong&gt;：同一 &lt;code&gt;trade_id&lt;/code&gt; 在同一侧出现了多条分录。&lt;code&gt;trade_id&lt;/code&gt; 相同说明这是重复，但无法判断「哪一组是正确的那组、哪一组该冲正」——自动冲正必须回答「冲哪一组」，而这个答案对账本身给不出来。开篇那个场景就落在这里：程序知道建行侧多了一条，但不知道该消哪条。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;金额不符&lt;/strong&gt;：两边金额对不上，而且结算结果看不出金额是对是错（结算结果本身的金额也是参与方上报的）。需要回溯原始支付指令确认。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;结算侧无记录&lt;/strong&gt;：可能是延迟、系统故障，也可能是欺诈交易。不自动处理。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;区分「能自动处理」和「只能人工裁决」的边界很清晰：&lt;strong&gt;当正确的修正动作能由权威数据源唯一确定时，可以自动处理；当存在多个可能的动作、或正确动作无法从数据里推断时，只能挂起等人裁决。&lt;/strong&gt; 漏记的补记动作由结算记录唯一确定（补、补多少都明确），所以能自动做；重复入账要冲正哪一组、金额不符到底哪边错，都存在歧义，所以只能人工。&lt;/p&gt;
&lt;p&gt;一个易混淆的点：&lt;strong&gt;本地账务的冲正和外部资金的退回是两件事。&lt;/strong&gt; 银行发现一笔跨行转账记重了，冲正的是本地账本里的多余分录——这不会自动把钱从对方银行退回来。那笔钱已经通过央行结算，结算最终性成立后不可撤销；要追回得走独立的退款流程，需要对方银行配合。如果资金还没完成结算，另一条路是止付——让央行拦下这笔尚未落地的资金，不再继续结算。对账 Job 只处理本地账务的一致性，不管外部资金的流向。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// settlement/recon/ReconJob.java（节选）：补记漏记分录&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;private&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;compensateMissing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bankUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ReconDiff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SettlementRecord&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;restTemplate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;postForEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bankUrl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/api/bank/compensate&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tradeId&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;fromBank&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getFromBank&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;toBank&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getToBank&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;amountCents&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmountCents&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;settledAt&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSettledAt&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;[recon] 补记完成: tradeId={}, 银行={}, 科目={}, 金额=¥{}.{}&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bankLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bankCode&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;account&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmountCents&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmountCents&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 补记失败不能中断整轮对账：记下来，下一批次重新核出同一条差异再补&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;[recon] 补记失败: tradeId={}, 银行={}&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bankCode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;补记动作本身在银行侧执行（&lt;code&gt;BankController.compensate&lt;/code&gt;），它复用和消费记账相同的 &lt;code&gt;LedgerService&lt;/code&gt;，所以补记的分录同样受借贷平衡和幂等约束——补一笔漏记，也是补「一组」分录（客户存款 + 存放央行款项），而不是孤零零补一条。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="六冲正不是删除是追加式更正"&gt;六、冲正不是删除，是追加式更正
&lt;/h2&gt;&lt;p&gt;「发现错了，能不能把那条错的分录删掉？」这是很多工程师的第一反应。答案是：&lt;strong&gt;不能。&lt;/strong&gt; 已入账的分录一旦写入，本 demo 的设计是禁止后续修改或删除的。&lt;/p&gt;
&lt;p&gt;这不是保守，而是两道硬约束决定的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;审计追溯&lt;/strong&gt;：外部审计师需要看到完整的账务变迁历史。原始记录消失，审计就失去了锚点。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;修复幂等&lt;/strong&gt;：如果允许删除原始记录然后重新写入，对账 Job 重复执行时就会把已修复的数据再次标记为差异，进入死循环。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;纸质账本上也是同一个道理：用圆珠笔记的账，写死的字划不掉，记错了只能在下面补记一笔反向的，两笔抵消，但两条笔迹都留在本子上。冲正的做法就是这一条：&lt;strong&gt;写一组金额相等、方向相反的新分录，来抵销原分录的影响。&lt;/strong&gt; 而且关键的是——冲正不是「逐条反」某一条 posting，而是按 &lt;code&gt;trade_id&lt;/code&gt; 找到这笔交易的&lt;strong&gt;整组&lt;/strong&gt;分录（客户存款 + 存放央行款项两条），一起反向。&lt;/p&gt;
&lt;p&gt;为什么必须整组反向？因为借贷平衡是对「一组分录」的约束。只反其中一条，这一组就不平了，&lt;code&gt;postEntries&lt;/code&gt; 的校验会直接把它拒掉。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// bank/LedgerService.java（节选）&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;reverseByTradeId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reverseEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findOriginalByTradeId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cm"&gt;/** 冲正的实际动作：为一组原始分录生成等额反向分录并写入 */&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;private&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;reverseEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;originals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;originals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reversals&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;originals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reversalEntryId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;REV-&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEntryId&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 已有冲正记录则幂等跳过：重复执行冲正不会写出第二组反向分录&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByEntryId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reversalEntryId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Direction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;revDir&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDirection&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="na"&gt;opposite&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reversals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reversalEntryId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTradeId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAccountId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;revDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmountCents&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTradeTime&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEntryId&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 整组反向分录同样走 postEntries，受借贷平衡校验&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;reversals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;postEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reversals&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reversals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;冲正的入口有两个：整笔撤销走 &lt;code&gt;reverseByTradeId&lt;/code&gt;，人工裁决「只冲多余那组」走 &lt;code&gt;reverseByEntryIds&lt;/code&gt;——后者必须由人指定冲哪几条，因为重复入账时一个 &lt;code&gt;trade_id&lt;/code&gt; 下有两组分录，整组冲正会把合法的那组也冲掉。两个入口共用 &lt;code&gt;reverseEntries&lt;/code&gt;，所以幂等和借贷平衡的行为完全一致。&lt;/p&gt;
&lt;p&gt;注意三个要点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;冲正必须保持借贷平衡。&lt;/strong&gt; 原分录是借记客户存款 ¥10,000、贷记存放央行款项 ¥10,000，冲正分录是贷记客户存款 ¥10,000（反向）、借记存放央行款项 ¥10,000（反向）。不是孤零零写一条 posting。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;reversal_of&lt;/code&gt; 指向原始分录。&lt;/strong&gt; 对账闭环验收时，通过这个链条确认「差异 → 原始分录 → 冲正分录 → 重新核对平了」。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;冲正只更正本地账务。&lt;/strong&gt; 前面说了，外部支付已经发生了，冲正不改外部事实。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;原始分录和冲正分录并排看（以工行付款侧为例，两条原始分录共享同一个 &lt;code&gt;trade_id&lt;/code&gt;，但各有独立的 &lt;code&gt;entry_id&lt;/code&gt;；&lt;code&gt;trade_id&lt;/code&gt; 是 &lt;code&gt;TRD-&lt;/code&gt; 加 8 位随机大写十六进制，&lt;code&gt;entry_id&lt;/code&gt; 的末尾 &lt;code&gt;CUST&lt;/code&gt; / &lt;code&gt;PBOC&lt;/code&gt; 是科目标记）：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;分录类型&lt;/th&gt;
					&lt;th&gt;分录 ID&lt;/th&gt;
					&lt;th&gt;方向&lt;/th&gt;
					&lt;th&gt;科目&lt;/th&gt;
					&lt;th&gt;金额&lt;/th&gt;
					&lt;th&gt;备注&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;原始&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;E-TRD-2B14449D-ICBC-CUST&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;借&lt;/td&gt;
					&lt;td&gt;客户存款&lt;/td&gt;
					&lt;td&gt;¥10,000&lt;/td&gt;
					&lt;td&gt;正常记账&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;原始&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;E-TRD-2B14449D-ICBC-PBOC&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;贷&lt;/td&gt;
					&lt;td&gt;存放央行款项&lt;/td&gt;
					&lt;td&gt;¥10,000&lt;/td&gt;
					&lt;td&gt;正常记账&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;冲正&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;REV-E-TRD-2B14449D-ICBC-CUST&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;贷&lt;/td&gt;
					&lt;td&gt;客户存款&lt;/td&gt;
					&lt;td&gt;¥10,000&lt;/td&gt;
					&lt;td&gt;抵销上面那笔借方&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;冲正&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;REV-E-TRD-2B14449D-ICBC-PBOC&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;借&lt;/td&gt;
					&lt;td&gt;存放央行款项&lt;/td&gt;
					&lt;td&gt;¥10,000&lt;/td&gt;
					&lt;td&gt;抵销上面那笔贷方&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;四条分录合在一起，净借贷合计仍然是 0。账平了，原始记录还在，审计师可以看到「发生过什么、怎么修正的」。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="七为什么对账不能替代幂等"&gt;七、为什么对账不能替代幂等
&lt;/h2&gt;&lt;p&gt;对账是事后发现，幂等是事前防护。很多人觉得「有了对账就不需要幂等」，这个想法有一个根本性的问题：&lt;strong&gt;对账只能发现「结果不一致」，它不知道「不一致的原因」。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;上一篇讲的消费端幂等，防的是「同一条消息被处理两次」；这里的对账，查的是「处理完之后三本账对不对得上」。两者在链路上前后相接：幂等让第一节的第三个断点（重复投递）尽量不发生，对账在它还是发生了之后把差异捞出来。&lt;/p&gt;
&lt;h3 id="有业务身份对账能做的远比人工裁决多"&gt;有业务身份，对账能做的远比「人工裁决」多
&lt;/h3&gt;&lt;p&gt;这个问题的关键不在于幂等键本身，而在于&lt;strong&gt;业务身份是否可追溯。&lt;/strong&gt; 如果每一笔交易都有一个唯一的 &lt;code&gt;trade_id&lt;/code&gt;，并且这个 ID 贯穿了从结算指令、银行分录、到对账匹配的整条链路，那么：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对账可以准确判断两笔相同金额的记录是不是同一笔交易的重复执行（因为 &lt;code&gt;trade_id&lt;/code&gt; 相同）。&lt;/li&gt;
&lt;li&gt;对账可以区分「两笔金额一样的合法交易」和「同一笔交易记了两次」。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;反过来，如果系统里根本没有 &lt;code&gt;trade_id&lt;/code&gt; 这样的业务身份，记账时只写了金额、方向和时间戳，那对账面对两条 ¥10,000 的借记分录时，完全无法判断这是一笔重复记账还是两笔独立的合法转账。&lt;/p&gt;
&lt;p&gt;但即便有了 &lt;code&gt;trade_id&lt;/code&gt;，重复入账的自动修复也&lt;strong&gt;不是理所当然的&lt;/strong&gt;。&lt;code&gt;trade_id&lt;/code&gt; 相同只能说明「这是同一笔交易产生了多余的分录」，却不能告诉你「哪一组是正确的那组、哪一组该冲正」。这也是为什么 demo 里对重复入账（DUPLICATE）的处理是&lt;strong&gt;标记人工裁决&lt;/strong&gt;，而不是自动冲正——自动冲正必须回答「冲哪一组」，而这个答案对账本身给不出来。&lt;/p&gt;
&lt;h3 id="幂等键的三个必要条件"&gt;幂等键的三个必要条件
&lt;/h3&gt;&lt;p&gt;幂等键（Idempotency Key）只是第一步。要让对账能做到自动修复，还需要三个条件同时满足：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;消费端实际执行唯一性控制。&lt;/strong&gt; 不是生产端带了一个键就叫幂等——消费端必须在写入前检查这个键是否已经处理过。如果消费端收到带相同键的消息直接再次写入，键就是摆设。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;去重记录与账务写入在同一个事务中。&lt;/strong&gt; 先检查去重再记账，中间断开事务，竞态条件下仍然可能重复写入。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同一键携带不同金额时能拒绝冲突。&lt;/strong&gt; 如果一个键第二次出现时带的金额和第一次不同，系统应该拒绝而不是覆盖。&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// bank/LedgerService.java（节选）：消费端的幂等写入&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;boolean&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;postIfNew&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getFirst&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getTradeId&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 幂等检查：这个 trade_id 已经记过账了？&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;existsByTradeId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 幂等跳过，不重复写入&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 记账：去重检查 + 分录写入在同一事务中完成&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;postEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;消费端 &lt;code&gt;BankSettlementConsumer.onSettlement&lt;/code&gt; 调用它，收到重复消息时返回 false 直接跳过——这是正常的幂等跳过，不是错误，所以不抛异常、照常 ack。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;核心结论：决定对账自动修复能力的，不是有没有幂等键，而是业务身份是否可追溯、以及能否据此确定「正确的动作」。&lt;/strong&gt; 有可追溯的业务身份，对账可以精准定位差异、判断漏记并补记；但没有业务身份、或虽有身份却无法判断哪边该修正（比如重复入账该冲哪一组），对账就只能标记差异，等着人来看。幂等键是让「不产生重复」成为可能，而对账负责在「已经产生差异」时给出能追溯的处理。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="八从银行对账到分布式系统设计"&gt;八、从银行对账到分布式系统设计
&lt;/h2&gt;&lt;p&gt;对账这件事，拆开来看，其实是一个通用分布式系统的设计骨架。把前面几节里出现的核心设计模式抽象出来，它们都对应着分布式系统中反复出现的工程手段。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;对账系统中的设计&lt;/th&gt;
					&lt;th&gt;解决的问题&lt;/th&gt;
					&lt;th&gt;分布式系统通用表达&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;借贷平衡约束&lt;/td&gt;
					&lt;td&gt;保证单方账本不自相矛盾&lt;/td&gt;
					&lt;td&gt;可校验的内部一致性：任何系统都应该有办法自证「状态没乱」，但不等于状态正确&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;业务身份（&lt;code&gt;trade_id&lt;/code&gt;）&lt;/td&gt;
					&lt;td&gt;让对账能准确匹配同一笔交易&lt;/td&gt;
					&lt;td&gt;全局唯一请求标识：跨服务追踪、去重、匹配的基础&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;追加式更正（冲正）&lt;/td&gt;
					&lt;td&gt;修正错误而不破坏审计链&lt;/td&gt;
					&lt;td&gt;不可变日志 + 补偿操作：Saga 的补偿动作、Event Sourcing 的逆事件&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;第三方结算数据&lt;/td&gt;
					&lt;td&gt;提供独立的事实来源&lt;/td&gt;
					&lt;td&gt;外部权威数据源：跨系统对账不能只靠两方互对&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;分级处理决策树&lt;/td&gt;
					&lt;td&gt;不同差异不同动作&lt;/td&gt;
					&lt;td&gt;错误分类 + 分级恢复策略：在途、补记、冲正、待查各有对应的处理方式&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;对账 Job 的幂等设计&lt;/td&gt;
					&lt;td&gt;重复执行不对数据造成二次伤害&lt;/td&gt;
					&lt;td&gt;可重入任务：修复操作的结果是幂等的&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这张表里的每一行，回到第一节的三幕链条上都有具体落点：借贷平衡管住一幕之内的自洽，&lt;code&gt;trade_id&lt;/code&gt; 把三幕串成同一笔交易，冲正让改错不毁历史，结算记录提供跨幕的裁判，分级决策决定每种断裂怎么收场。&lt;/p&gt;
&lt;h3 id="对账最大的工程价值分离发现和修复"&gt;对账最大的工程价值：分离「发现」和「修复」
&lt;/h3&gt;&lt;p&gt;很多人把对账理解成「一个定时任务，跑完报一堆差异」。但它的工程价值不在「发现差异」——任何简单的比对脚本都能做到。对账的真正价值在于：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对账确定了「差多少」，分级处理确定了「怎么补回去」。&lt;/strong&gt; 这两个问题被拆成两个独立的步骤，各自有独立的执行逻辑、独立的幂等保证。&lt;/p&gt;
&lt;p&gt;这个分离是金融系统能在出错后继续运行的基石。如果发现差异的同时就「自动修正」，那修正错了怎么办？修正再修正？系统会陷入不可追溯的循环。但把它们分开——对账先给出一个&lt;strong&gt;不可变的事实陈述&lt;/strong&gt;（「2026 年 9 月 14 日的日终核对显示，trade_id=TRD-2B14449D 在工行的客户存款科目上缺失分录」），然后分级处理再给出&lt;strong&gt;可追溯的修正动作&lt;/strong&gt;（「根据结算记录补记分录 entry_id=COMP-TRD-2B14449D-ICBC-CUST」）——每一步的结果都可以被验证、被追溯、被重新评估。&lt;/p&gt;
&lt;h3 id="闭环验收没有重新核对就不算修好"&gt;闭环验收：没有重新核对就不算修好
&lt;/h3&gt;&lt;p&gt;修复动作完成后，不能以「生成了补记分录」或「写入了冲正记录」作为成功标准。真正的成功标准是：&lt;strong&gt;对当前这批数据重新跑一遍对账逻辑，差异消失了。&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// settlement/recon/ReconJob.java（节选）：闭环验收&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;private&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;verifyClosedLoop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ReconDiff&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SettlementRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlementMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Instant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Instant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 只有「本轮自动处理过」的差异参与闭环。挂起等人工的、等下一批次的不算失败——&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 它们本来就不该在这一轮被修好，算进失败会让「闭环失败」失去信号意义。&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ReconDiff&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;autoFixed&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ReconDiffType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MISSING&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlementMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;containsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autoFixed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;[recon] 本轮无自动处理的差异，跳过闭环验收&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 重新拉取净分录（已被冲正的 journal 不再参与），复用 checkBank 重新核对&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;aRecheck&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;groupByTrade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetchBankEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bankAUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bRecheck&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;groupByTrade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetchBankEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bankBUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReconDiff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;autoFixed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SettlementRecord&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlementMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Bank&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bank&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bankCode&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bank&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ICBC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;aRecheck&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bRecheck&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOrDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ReconDiff&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;recheckDiffs&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;boolean&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;checkBank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;recheckDiffs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;account&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;[recon] 闭环通过: {} 条自动处理的差异已重新核对为平&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;autoFixed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;[recon] 闭环失败: {} 条差异补记后仍不平: {}&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这里有一个容易被忽略的细节：闭环验收&lt;strong&gt;不能用「数一下分录条数对不对」这种简化判断&lt;/strong&gt;。因为冲正是追加式的，原始分录不会删除，冲正后「原始分录条数」不会变——只有按「净分录」（原始分录中尚未被冲正的部分）重新跑一遍完整的匹配逻辑，才能确认差异真正消失。&lt;/p&gt;
&lt;p&gt;闭环验收 + 不可变差异记录，构成了对账系统最底层的安全网：&lt;strong&gt;对账能证明自己修好了，而不是「相信」自己修好了。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="参考资料"&gt;参考资料
&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;中国人民银行, &lt;em&gt;小额支付系统业务处理手续（试行）&lt;/em&gt;, 央行官网公开文档. &lt;a class="link" href="https://www.pbc.gov.cn/eportal/fileDir/history_file/files/att_19041_2.pdf" target="_blank" rel="noopener"
 &gt;https://www.pbc.gov.cn/eportal/fileDir/history_file/files/att_19041_2.pdf&lt;/a&gt; —— 冲正、止付与退回流程的官方定义&lt;/li&gt;
&lt;li&gt;金融时报, &lt;em&gt;网联平台荣获 2019 年度银行科技发展奖特等奖&lt;/em&gt;, 2020-09-25. &lt;a class="link" href="https://www.financialnews.com.cn/if/hydt/202009/t20200925_201999.html" target="_blank" rel="noopener"
 &gt;https://www.financialnews.com.cn/if/hydt/202009/t20200925_201999.html&lt;/a&gt; —— 网联「三地六中心」架构与小时级多批次对账设计&lt;/li&gt;
&lt;li&gt;ACCA, &lt;em&gt;Suspense accounts and error correction&lt;/em&gt;, Foundation Level Study Resources. &lt;a class="link" href="https://www.accaglobal.com/gb/en/student/exam-support-resources/foundation-level-study-resources/ffa/ffa-technical-articles/suspense-accounts-error-correction.html" target="_blank" rel="noopener"
 &gt;https://www.accaglobal.com/gb/en/student/exam-support-resources/foundation-level-study-resources/ffa/ffa-technical-articles/suspense-accounts-error-correction.html&lt;/a&gt; —— 试算平衡无法发现的六类错账&lt;/li&gt;
&lt;li&gt;Formance, &lt;em&gt;Transaction Reconciliation at Scale: An Engineering Guide&lt;/em&gt;. &lt;a class="link" href="https://www.formance.com/blog/engineering/transaction-reconciliation-at-scale-for-eng" target="_blank" rel="noopener"
 &gt;https://www.formance.com/blog/engineering/transaction-reconciliation-at-scale-for-eng&lt;/a&gt; —— 对账前的四项账本不变约束：复式记账、幂等、不可变性、双时态&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;演示代码&lt;/strong&gt;：完整实现在 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/c03-reconciliation" target="_blank" rel="noopener"
 &gt;GYD 仓库 &lt;code&gt;c03-reconciliation&lt;/code&gt;&lt;/a&gt;，PostgreSQL 编排在仓库级 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/infra" target="_blank" rel="noopener"
 &gt;&lt;code&gt;infra/&lt;/code&gt;&lt;/a&gt;。按各目录 README 的命令跑，不要复制这篇文章里零散的代码块。&lt;/p&gt;

 &lt;/blockquote&gt;</description></item><item><title>第二话｜消息队列的可靠投递与可靠消费：不丢靠三段兜底，不重只能靠幂等</title><link>https://zh.renxinblog.cn/post/gyd-c02-mq-reliable-delivery/</link><pubDate>Wed, 11 Feb 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/gyd-c02-mq-reliable-delivery/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/gyd-c02-mq-reliable-delivery-cover.png" alt="Featured image of post 第二话｜消息队列的可靠投递与可靠消费：不丢靠三段兜底，不重只能靠幂等" /&gt;&lt;p&gt;一张订单表里多了一行，下游的发货系统没有收到这条消息。&lt;/p&gt;
&lt;p&gt;订单服务在创建订单成功后投递 &lt;code&gt;order.created&lt;/code&gt;，发货、积分、通知三个下游各自消费它。这一笔的发送日志打着成功，订单表里有数据，下游的队列里没有消息。&lt;strong&gt;这条消息死在了哪一段？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;消息队列从进入架构的第一天就带着「可靠」的承诺，但这句承诺不是某一个组件给的，而是生产者、Broker、消费者各兜一段才拼出来的。任何一段缺了兜底，都会出现「发送成功、下游没收到」。&lt;/p&gt;
&lt;p&gt;这条链路的骨架：消息穿过生产者、Broker、消费者三个边界，Broker 里是交换机到队列的一段路由；路径上还有两个反向回执——Broker 回给生产者的 confirm、消费者回给 Broker 的 ack。&lt;/p&gt;
&lt;p&gt;&lt;img alt="RabbitMQ 消息数据流与三段边界架构图" class="gallery-image" data-flex-basis="652px" data-flex-grow="272" decoding="async" height="250" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c02-mq-reliable-delivery-architecture.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;顺着这条消息的生命周期走一遍，它有三个可能消失的位置：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;发送时&lt;/strong&gt;：生产者以为发出去了，消息却没进 Broker，或者进了 Broker 却没进任何队列。那笔订单的发送日志打的就是「成功」。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;存储时&lt;/strong&gt;：Broker 收下了，回执也回了，消息还没挺过一次重启就从磁盘上消失。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;消费时&lt;/strong&gt;：消费者读到了，处理到一半进程挂掉，重启后不知道这一笔该不该再处理。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这三个位置分别由生产者、Broker、消费者兜住，彼此没有替代关系：生产端再严谨也管不到消费者崩溃，Broker 副本再多也管不到生产者发错了路由键。&lt;/p&gt;
&lt;p&gt;这条链路上有两类问题，经常被混在一起谈：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;不丢&lt;/strong&gt;：一条消息从产生到被处理完，中途不能消失。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不重&lt;/strong&gt;：同一笔业务不能被处理两次。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;在 Broker 这一层，这两类目标是冲突的：要保证不丢，就得允许重复投递。真正的「既不丢也不重」要到消费端用幂等去拼，Broker 给不了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;GYD 这个系列讲分布式系统里各种解决方案的实现方式。消息队列出现得早，也最容易被当成「基础设施，接上就行」的一环，而可靠性缺口通常就藏在这个默认判断里。&lt;/p&gt;
&lt;p&gt;这三段在 RabbitMQ 4.3.5 上分别跑一遍。结论对应的是 RabbitMQ 4.3.5 + Spring Boot 4.1.1 + Spring AMQP 4.1.1 + amqp-client 5.30.0 + JDK 21；完整实现、端点对照与复现方式在仓库 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/c02-mq-reliable-delivery" target="_blank" rel="noopener"
 &gt;&lt;code&gt;c02-mq-reliable-delivery&lt;/code&gt;&lt;/a&gt;，下面每处结论后面也各自带了落点。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="一生产端confirm-和-return-是两件事"&gt;一、生产端：confirm 和 return 是两件事
&lt;/h2&gt;&lt;p&gt;RabbitMQ 的投递路径是 &lt;strong&gt;生产者 → 交换机 → 绑定 → 队列&lt;/strong&gt;。生产者把消息发给交换机，交换机再根据绑定关系决定它进哪些队列。&lt;/p&gt;
&lt;p&gt;消息先到交换机这一层，意味着「到了 Broker」和「进了队列」是两个可以分开发生的事件。RabbitMQ 给了两个独立机制来报告它们：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;机制&lt;/th&gt;
					&lt;th&gt;报告的是什么&lt;/th&gt;
					&lt;th&gt;触发条件&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;publisher confirm&lt;/td&gt;
					&lt;td&gt;视路由结果分情况：可路由＝路由到的队列已接收；不可路由＝确认没有目标队列&lt;/td&gt;
					&lt;td&gt;消息被交换机处理之后&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;publisher return&lt;/td&gt;
					&lt;td&gt;消息没能路由到任何队列&lt;/td&gt;
					&lt;td&gt;交换机找不到匹配的绑定，且投递时设置了 &lt;code&gt;mandatory&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这两道回执在配置里是两个分开的开关（&lt;code&gt;publisher-confirm-type&lt;/code&gt; 收 confirm、&lt;code&gt;publisher-returns&lt;/code&gt; 收退回），默认都不开；客户端代码侧还要给发送模板打上 &lt;code&gt;mandatory&lt;/code&gt; 标记、并分别注册两个回调。缺了任何一处，等于少了一道回执。&lt;/p&gt;
&lt;p&gt;把一条消息投到没有任何队列绑定的路由键上，日志会连出三件事：生产端记下自己把消息投了出去；Broker 退回一条 &lt;code&gt;NO_ROUTE&lt;/code&gt;，说明没有任何队列接收它；紧接着是一条 confirm 的 ack，说 Broker 已确认。再去查队列深度，两个业务队列都是 0 条（端点 &lt;code&gt;unroutable&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;confirm 返回了 ack，消息已经被丢掉了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;官方文档在描述这件事时语气很平淡：「对不可路由的消息，Broker 会在交换机确认没有队列可路由之后发出 confirm；如果消息同时以 mandatory 方式发布，&lt;code&gt;basic.return&lt;/code&gt; 会在 &lt;code&gt;basic.ack&lt;/code&gt; 之前发给客户端。」文档没有单独警告不处理 return 会怎样，但把两者的定义摆在一起，结论是清楚的：&lt;strong&gt;confirm 的 ack 不是一律「成功」，它跟着路由结果走。路由到了队列，ack 表示队列已接收；路由不到，ack 只表示「确认了没有队列」，这条消息已经丢了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;只监听 confirm 而不管 return 的服务，等于在发送环节装了一个常亮的绿灯。&lt;/p&gt;
&lt;p&gt;这里还有个更隐蔽的边界：return 的语义是「一个队列都没路由到」，所以「没有 return」只说明&lt;strong&gt;至少有一个队列接收了&lt;/strong&gt;，不能证明发货、积分、通知三个预期订阅都配置正确。漏配了一个订阅，消息照样 confirm ack、照样不 return，只是那个下游安静地没收到。这个边界单靠发送端的回执是看不见的。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 A["投递一条消息"] --&gt; B{"有队列绑定这个路由键吗"}
 B -- 有 --&gt; C["消息进入队列&lt;br/&gt;confirm: ack"]
 B -- 没有 --&gt; D["没有队列接收&lt;br/&gt;confirm: ack&lt;br/&gt;return: NO_ROUTE"]&lt;/pre&gt;&lt;p&gt;需要等回执再继续的场景，可以同步等确认结果（示例里给了 3 秒超时）。这里有一处容易写漏：不可路由时 confirm 依然返回 ack，此时消息已被退回、并未进队列，所以&lt;strong&gt;只查 ack 会把「路由不到队列」当成「发送成功」，必须再查一次退回结果&lt;/strong&gt;。同步等回执会拖住发送方。文档里给了个量级：在持续负载下，ack 的延迟可以达到数百毫秒，因此它建议把 confirm 当作异步事件流处理，或者批量投递、统一等待整批回执。同步等待更适合这里这种单条验证，一眼就能看出这一条到底有没有被 Broker 收下。&lt;/p&gt;
&lt;p&gt;生产端的可靠性到这里只解决了一半。ack 在「路由不到队列」时并不代表成功，不是回执说了假话，是应用把成功条件理解少了：只盯 confirm 不看 return，就把「没有队列」当成了「发送成功」。而一旦把发送失败当成功、靠重试兜底，「同一笔订单发了两遍」的概率就不再为零。&lt;strong&gt;可靠性问题的另一半，是从生产端的重试里长出来的。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="二broker-端持久化要分清消息恢复和拓扑恢复"&gt;二、Broker 端：持久化要分清「消息恢复」和「拓扑恢复」
&lt;/h2&gt;&lt;p&gt;消息进了队列，下一个问题是它会不会在 Broker 这一侧消失。这里要先把两件事分开：&lt;strong&gt;已经入队的消息能不能挺过重启&lt;/strong&gt;，和&lt;strong&gt;重启后拓扑（交换机、绑定）还在不在&lt;/strong&gt;。它们由不同的属性决定，不能混成一句话。&lt;/p&gt;
&lt;p&gt;对一条 classic queue 里的消息，「能不能挺过重启」只看两个属性：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;属性&lt;/th&gt;
					&lt;th&gt;落点&lt;/th&gt;
					&lt;th&gt;缺了会怎样&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;队列持久化&lt;/td&gt;
					&lt;td&gt;声明队列时标 &lt;code&gt;durable&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;重启后队列连同消息一起消失&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;消息持久化&lt;/td&gt;
					&lt;td&gt;投递时标 &lt;code&gt;deliveryMode=2&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;队列还在，队列里的这条消息不在&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;交换机的 &lt;code&gt;durable(true)&lt;/code&gt; 是另一件事，管的是&lt;strong&gt;拓扑恢复&lt;/strong&gt;：它决定重启后这个交换机还在不在、还能不能继续往它投，但&lt;strong&gt;不决定已经入队的消息能否恢复&lt;/strong&gt;。所以别把交换机、队列、消息并列成「三个都要持久化」——交换机那条管的是「以后还能不能投」，跟「已经投进去的还在不在」是两码事。&lt;/p&gt;
&lt;p&gt;官方文档对「能不能挺过重启」的说法是：durable queue 的元数据写磁盘，重启时会被恢复，「包括其中以 persistent 方式发布的消息。以 transient 方式发布的消息会在恢复过程中被丢弃，&lt;strong&gt;即使它们的队列是 durable 的&lt;/strong&gt;。」&lt;/p&gt;
&lt;p&gt;这两个属性里，消息持久化这条更容易被漏：队列的 &lt;code&gt;durable&lt;/code&gt; 在声明时顺手就写了，消息的 &lt;code&gt;deliveryMode&lt;/code&gt; 却要每次投递单独指定。&lt;/p&gt;
&lt;p&gt;用一条不挂消费者的持久化探针队列做对照（这个实验只在 classic queue 上成立，quorum queue 的持久化不依赖 deliveryMode）：投一条持久化、一条非持久化，两条都会收到 confirm 的 ack；重启 Broker 之后队列深度从 2 掉到 1，&lt;code&gt;messages_persistent&lt;/code&gt; 一直是 1。再单独投一条非持久化消息复验：重启前深度 1，重启后归 0（端点 &lt;code&gt;probe&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ack 是否意味着落盘，取决于消息和队列的持久化属性。&lt;/strong&gt; 文档给「持久化消息投到持久化队列」这种组合打了包票，那时 ack 意味着已经写盘。但只要消息属性没标持久化，ack 照样返回，重启后它无声消失。&lt;/p&gt;
&lt;p&gt;单机持久化挡得住进程重启，挡不住磁盘损坏和机器故障。要扛机器级故障，只能用副本，在 RabbitMQ 4.x 上意味着 &lt;strong&gt;quorum queue&lt;/strong&gt;——文档里那句「Classic queue mirroring was removed starting with RabbitMQ 4.0」，把过去的退路一并关掉了。&lt;/p&gt;
&lt;p&gt;quorum queue 基于 Raft，副本关系里有一个 leader 和若干 follower，可用性由「多数派是否在线」决定。单节点验证不了这件事，所以另起了一套 3 节点集群（&lt;code&gt;infra/rabbitmq-cluster/&lt;/code&gt;，端口与单节点错开，可以同时运行）。观察到的是这样一串：声明出来的 quorum queue 副本组实为 3 个成员、leader 落在 rmq1；投 3 条持久化消息全部收到 confirm；停掉 leader 所在节点后重新选主（两次运行分别落在 rmq2 与 rmq3），2/3 在线时继续投 2 条都成功，而且能取回全部 5 条，原 3 条一条不少；再停一个、只剩 1/3 时投递被拒，15.0s 后返回 &lt;code&gt;NackError: 0 message(s) NACKed&lt;/code&gt;；把停掉的节点恢复回来，队列自愈到 3/3、投递恢复，而被 NACK 的那条并没有留在队列里（脚本 &lt;code&gt;quorum-majority.py&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;quorum queue 的可用性由多数派决定：2/3 在线可以读写，掉到 1/3 就无法提交写入。&lt;/strong&gt; 这不是丢消息，是 Raft 为避免脑裂做的取舍：宁可拒绝写入，也不让两个分区各自接受写入。客户端看到的表现不一定是即时报错，可能是等待、超时，也可能是明确的 NACK（本次实测是 15s 后返回 &lt;code&gt;NackError&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;顺带一个观测上的坑：失去多数派时，连读队列视图本身都会超时，&lt;code&gt;list_queues&lt;/code&gt; 时好时坏。故障期间的监控图表断点，有一部分来自这里。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;维度&lt;/th&gt;
					&lt;th&gt;单机 durable queue&lt;/th&gt;
					&lt;th&gt;quorum queue&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;数据存活范围&lt;/td&gt;
					&lt;td&gt;单节点磁盘&lt;/td&gt;
					&lt;td&gt;多数派副本&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;扛得住什么&lt;/td&gt;
					&lt;td&gt;进程重启、节点重启&lt;/td&gt;
					&lt;td&gt;节点重启、单机磁盘损坏、少数派节点丢失&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;可用性前提&lt;/td&gt;
					&lt;td&gt;节点在线&lt;/td&gt;
					&lt;td&gt;多数派在线（3 节点可容忍 1 个）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;代价&lt;/td&gt;
					&lt;td&gt;—&lt;/td&gt;
					&lt;td&gt;每次投递要复制到多数派，写入路径更长&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;quorum queue 的 confirm 语义也跟着变严：&lt;strong&gt;只有在消息被复制到多数派成员之后，Broker 才会发出 publisher confirm。&lt;/strong&gt; 这一点和单机队列不同，单机队列上「收到 ack」离「数据安全」还有距离，quorum queue 上这句话的分量更重。&lt;/p&gt;
&lt;p&gt;副本组本身也有几条固定约束：quorum queue 必须是持久化队列（Raft 的副本机制不建立在内存队列上）；官方建议副本数为奇数，默认 group size 是 3；文档明确写了「不要在多过 7 个 RabbitMQ 节点上运行 quorum queue」，也提醒少于 3 个节点的集群拿不到 quorum queue 的完整保证。本次实测的 3 节点加 group size 3，正好是能观察到选主、多数派可用性和少数派拒写的最小配置。&lt;/p&gt;
&lt;h2 id="三消费端ack-时机与-prefetch-决定崩溃后重投多少条"&gt;三、消费端：ack 时机与 prefetch 决定崩溃后重投多少条
&lt;/h2&gt;&lt;p&gt;消息安全躺在队列里，最后一个缺口在消费者手里。&lt;strong&gt;ack 的时机就是一次投票：投给「不丢」，还是投给「不重」。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;处理成功后再确认、还是先确认再处理，是两个方向相反的取舍：先处理成功后 ack，中途崩溃消息会重投，于是可能重复处理；先 ack 再处理，中途崩溃时消息已删除，于是可能丢失。&lt;code&gt;autoAck&lt;/code&gt; 是后者的极端形式，消息一交给消费者就当作已确认，跑得最快，也最不设防。&lt;/p&gt;
&lt;p&gt;这里要插一句和 Spring 的边界：上面两种只是 AMQP 层的 &lt;code&gt;manual&lt;/code&gt; / &lt;code&gt;autoAck&lt;/code&gt;；Spring 还有一个默认的 &lt;code&gt;AUTO&lt;/code&gt; 模式，它是容器在监听方法成功返回后替你 ack，比 &lt;code&gt;autoAck&lt;/code&gt; 保守，比 &lt;code&gt;manual&lt;/code&gt; 省事。这套 demo 用的是 &lt;code&gt;manual&lt;/code&gt;，配置里关掉框架自动确认，把 ack 时机完全交给业务代码：业务成功才 ack，失败则带 &lt;code&gt;requeue=false&lt;/code&gt; 拒绝，交给重试链路（不重回原队列，否则会原地死循环）。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 A["收到一条消息"] --&gt; B{"先做哪一步"}
 B -- "先处理，成功后 ack" --&gt; C["中途崩溃&lt;br/&gt;消息重投，可能重复处理"]
 B -- "先 ack，再处理" --&gt; D["中途崩溃&lt;br/&gt;消息已删，可能丢失"]&lt;/pre&gt;&lt;p&gt;选完 ack 时机，还有一个必须显式决定的参数：prefetch。&lt;/p&gt;
&lt;p&gt;RabbitMQ 的消费模型是「&lt;strong&gt;未确认消息的集合&lt;/strong&gt;」，不是 Kafka 那种记「读到哪了」的位点。Broker 把消息推给消费者，在收到 ack 之前一直记着这些消息属于这个消费者，没有 ack 就没有「位点前进」，只有「这条还没结清」。于是 prefetch（协议里的 &lt;code&gt;basic.qos&lt;/code&gt;）决定了&lt;strong&gt;一个消费者最多同时持有多少条未确认消息&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这个参数不影响单条消息的处理速度，它影响的是消费者崩溃时有多少条要重投。用 50 条慢消息（每条处理 200ms）只改 prefetch 来对照：设成 1 时未确认条数恒为 1，设成 20 时恒为 20，而 50 条排空都花约 10s（端点 &lt;code&gt;burst&lt;/code&gt;）。&lt;strong&gt;两者的吞吐相同，差别在同时在途的未确认条数。&lt;/strong&gt; 如果消费者在任一时刻崩溃，&lt;code&gt;prefetch=1&lt;/code&gt; 最多重投 1 条，&lt;code&gt;prefetch=20&lt;/code&gt; 最多重投 20 条。&lt;/p&gt;
&lt;p&gt;这里有个边界要说清楚：本实验的瓶颈在消费处理本身（每条固定 200ms），所以两者耗时相同。如果消费逻辑很快、网络往返占比高，prefetch 取值会明显影响吞吐，那是另一个结论，不能从这组数据外推。表里的「恒为」也只成立在积压充足阶段，消息源源不断时未确认条数稳定在 prefetch 上限；排空的最后一段，队列里剩不足 prefetch 条，未确认条数会掉下来。另外，实验只测了单消费者，多消费者共用一个队列时的负载分摊没有测。&lt;/p&gt;
&lt;p&gt;还有一件事值得和 prefetch 分开看：消费者崩溃后，那些未确认的消息会被重新投递，但&lt;strong&gt;不一定回到同一个消费者&lt;/strong&gt;。Broker 把它们放回队列等重新分配，多个消费者在场时通常落到别人手里。这就是为什么扩容之后「同一笔业务被两个消费者各处理一次」会更常见，幂等在那里不再是理论问题。&lt;/p&gt;
&lt;p&gt;至于 prefetch 该定多少，没有一个可以照抄的数。它取决于单条消息的处理耗时、下游能承受的并发、以及你愿意接受崩溃时重投多少条。文档里 &lt;code&gt;0&lt;/code&gt; 表示不设上限，等于把未确认集合交给内存和连接去决定，生产环境一般不会这么配。Broker 侧另有一个「消费者没指定时生效」的默认 prefetch，可在高级配置里调整（文档示例给的值是 250），所以不显式设置并不等于没有限制，只等于这个数由别人替你定了。&lt;/p&gt;
&lt;h2 id="四不丢和不重分别落在两层broker-管投递幂等管效果"&gt;四、不丢和不重分别落在两层：Broker 管投递，幂等管效果
&lt;/h2&gt;&lt;p&gt;前三段各自解决了一类丢失，代价是把重复引了进来：生产端的重试会重发，消费端的 ack 时机（先处理再确认）会重投。&lt;strong&gt;要理解这件事的边界，得先看清 Broker 能承诺什么。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;官方可靠性文档把话说得很直白：「使用 acknowledgements 保证 &lt;strong&gt;at least once&lt;/strong&gt; 投递。不使用 acknowledgements 时，发布和消费过程中都可能丢消息，只能保证 &lt;strong&gt;at most once&lt;/strong&gt;。」至于 exactly-once，文档在同一页给出了它的现实形态：生产者重传、确认丢失时消息可能重复，「因此消费端应用需要做去重，或者以幂等的方式处理收到的消息。」&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Broker 能给的只有 at-least-once 和 at-most-once。exactly-once 不是队列的承诺，而是「at-least-once 投递 + 消费端幂等」拼出来的效果，而且这个 exactly-once 只覆盖被幂等机制管住的那些业务效果。&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;投递语义&lt;/th&gt;
					&lt;th&gt;谁提供&lt;/th&gt;
					&lt;th&gt;结果&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;at-most-once&lt;/td&gt;
					&lt;td&gt;关掉 ack / autoAck&lt;/td&gt;
					&lt;td&gt;不重，可能丢&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;at-least-once&lt;/td&gt;
					&lt;td&gt;手动 ack + 失败重投&lt;/td&gt;
					&lt;td&gt;不丢，可能重&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;exactly-once&lt;/td&gt;
					&lt;td&gt;队列不提供&lt;/td&gt;
					&lt;td&gt;由 at-least-once + 消费端幂等拼出&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这里要分清三个层次，它们经常被混成一句话。&lt;strong&gt;投递&lt;/strong&gt;可以重复：Broker 重投、生产者重发都会让同一条消息到两次。&lt;strong&gt;执行&lt;/strong&gt;可以重复：处理函数被调用两次。&lt;strong&gt;业务副作用&lt;/strong&gt;能不能只生效一次，取决于消费端有没有幂等，而且幂等只覆盖它自己管得住的那些副作用。数据库去重表能让「改库」只发生一次，但它不会自动让发货、短信、外部扣款也只发生一次；那些副作用要各自有幂等键（发货单号、短信去重、扣款流水号），或者接受「至少一次 + 人工对账」。&lt;/p&gt;
&lt;p&gt;注意幂等这一层必须落在&lt;strong&gt;业务侧&lt;/strong&gt;。Broker 手里也有一些计数，比如 quorum queue 会把一条消息失败的投递次数记在 &lt;code&gt;x-delivery-count&lt;/code&gt; 头里，配合 &lt;code&gt;delivery-limit&lt;/code&gt; 策略，投递超过上限的消息会被死信掉，而它解决的是「一条毒消息别在队列里无限打转」，不是「你的业务代码只跑一次」。这两个问题在不同的层，别指望前一层替后一层兜住。（&lt;code&gt;delivery-limit&lt;/code&gt; 的计数规则在 4.3 有调整，不是所有显式 nack 都计入失败次数，与第六节讲的 delayed retry 相关。）&lt;/p&gt;
&lt;p&gt;落回订单场景：这一笔订单可能被处理两次。幂等的最小形态是让消息带上业务唯一键，消费端用它去重，这套 demo 里就是消息体上的 &lt;code&gt;orderId&lt;/code&gt;。它由订单服务生成，重发时不变；消费端拿到它，先查这一步业务是否已经做过，再决定要不要执行。但要小心并发：去重记录和业务写入必须原子提交（比如唯一索引 + 同库事务），否则两个消费者同时「查无记录」、都往下执行，幂等就失效了。去重表、唯一索引、状态机校验各有各的适用场景，本 demo 没有落到那一层，&lt;code&gt;orderId&lt;/code&gt; 只负责把「同一笔业务」这个身份带过去。&lt;/p&gt;
&lt;h2 id="五兜底死信与延迟重试链路"&gt;五、兜底：死信与延迟重试链路
&lt;/h2&gt;&lt;p&gt;三段兜底之外，还有一类问题没有解决：消费一直失败怎么办。失败一次就重投，会得到一个高速空转的循环；失败三次就扔掉，会丢掉一条本该被处理的消息。&lt;/p&gt;
&lt;p&gt;RabbitMQ 在这一层的原语是&lt;strong&gt;死信&lt;/strong&gt;。消息在四种情况下会被重新投递到死信交换机：消费者以 &lt;code&gt;requeue=false&lt;/code&gt; 拒绝（&lt;code&gt;basic.nack&lt;/code&gt; / &lt;code&gt;basic.reject&lt;/code&gt;）、消息因 TTL 到期（per-message TTL，或队列级 &lt;code&gt;message-ttl&lt;/code&gt;）、队列超过长度上限、以及 quorum queue 上超过 &lt;code&gt;delivery-limit&lt;/code&gt; 的投递次数。&lt;/p&gt;
&lt;p&gt;这里有两个容易踩到的地方。如果队列上配了死信交换机，但消息需要死信时那个交换机并不存在，&lt;strong&gt;消息会被静默丢弃&lt;/strong&gt;，没有回执，没有异常。拓扑声明因此不能只声明队列，交换机、绑定、队列的生命周期要一起管。&lt;/p&gt;
&lt;p&gt;另一个和队列类型有关：classic queue 的死信过程默认不带 publisher confirm，消息从原队列删除后直接投给死信目标，目标不可用时它就丢了。quorum queue 可以做得更强，但&lt;strong&gt;默认仍是 at-most-once&lt;/strong&gt;，要显式配置 &lt;code&gt;dead-letter-strategy=at-least-once&lt;/code&gt;、&lt;code&gt;overflow=reject-publish&lt;/code&gt; 并配好死信交换机，才切到带 confirm 的 at-least-once 死信。同一套死信拓扑，队列类型和策略配置不同，兜底强度并不一样。本 demo 没有显式配置这套策略，死信转移仍是 at-most-once 语义。&lt;/p&gt;
&lt;p&gt;要做「重试 N 次再进死信」，标准做法是让业务队列的死信出口指向一条重试链路：业务队列 → nack → 重试交换机 → TTL 重试队列 → 到期回业务交换机 → 业务队列。这套 demo 的拓扑里，业务队列的死信出口不是最终死信队列，而是重试交换机；重试队列上配了 2000ms 的队列级 TTL，到期后经自身的死信出口回到业务交换机；最终死信交换机只接重试用尽以后由应用显式投递的消息。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 Q["业务队列&lt;br/&gt;收到消息"] --&gt; C["处理失败&lt;br/&gt;nack(requeue=false)"]
 C -- "还没用尽重试" --&gt; R["重试队列&lt;br/&gt;TTL 2000ms"]
 R -- "到期回投，x-death 计数 +1" --&gt; Q
 C -- "重试用尽" --&gt; D["最终死信队列&lt;br/&gt;等待人工或定时任务"]&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;重试计数不一定非要应用自己维护，本 demo 读 Broker 写在 &lt;code&gt;x-death&lt;/code&gt; 头里的死信记录。&lt;/strong&gt; 应用自己维护一个进程内计数器，进程重启后就归零；要可靠就得把次数持久化到数据库、任务记录或消息头里，那是另一套存储。本 demo 选择读 &lt;code&gt;x-death&lt;/code&gt;，它跟着消息走，避免了另建一套重试计数存储。官方文档对它的定义是：每条记录里 &lt;code&gt;count&lt;/code&gt; 表示「这条消息因为这个原因从这个队列被死信了多少次」，并且记录会按 &lt;code&gt;{队列, 原因}&lt;/code&gt; 压缩，同一个 (队列, 原因) 组合只保留一条，&lt;code&gt;count&lt;/code&gt; 累加。&lt;/p&gt;
&lt;p&gt;还要说清一个边界：&lt;code&gt;x-death&lt;/code&gt; 记的是「死信」次数，不是「投递」次数。消费者崩溃后消息被直接重投、没走死信的那一次，不会在 &lt;code&gt;x-death&lt;/code&gt; 里留记录。所以「rejected 次数 + 1 = 累计投递次数」这个等式，只在「每次失败都走 nack 死信」的本实验路径下成立。&lt;/p&gt;
&lt;p&gt;实测一条一直失败的消息（&lt;code&gt;orderId&lt;/code&gt; 里带 &lt;code&gt;fail&lt;/code&gt;），队列级 TTL 2000ms、最多重试 3 次。第一次失败时 &lt;code&gt;x-death&lt;/code&gt; 还是空的；第二次时里面已经有两条记录，业务队列上一条 &lt;code&gt;rejected&lt;/code&gt; 计数 1、重试队列上一条 &lt;code&gt;expired&lt;/code&gt; 计数 1；第三次两边各加 1；到第 4 次投递仍失败，消息转入最终死信队列，此时累计投递 4 次，也就是首次加上 3 次重试。相邻两次处理的间隔实测 2004 / 2003 / 2003ms，与配置的 2000ms 对得上（重跑会有几毫秒浮动）。&lt;/p&gt;
&lt;p&gt;计数时要挑对条目：同一次失败会留下两条记录，业务队列上一条 &lt;code&gt;reason=rejected&lt;/code&gt;，重试队列上一条 &lt;code&gt;reason=expired&lt;/code&gt;，两个都算就重复计数了。示例里的计数只挑与业务队列同名的 &lt;code&gt;rejected&lt;/code&gt; 记录求和。&lt;/p&gt;
&lt;p&gt;重试用尽之后怎么收尾，有一个容易写错的地方。这里的业务队列死信出口是重试交换机，所以&lt;strong&gt;不能再用 &lt;code&gt;nack&lt;/code&gt; 结束&lt;/strong&gt;，它会再一次进入重试循环。正确做法是显式投到最终死信交换机，再 ack 掉原消息，同时把尝试次数写进消息头备查。&lt;/p&gt;
&lt;h2 id="六单个-ttl-重试队列的限制混合延迟会出现队头阻塞"&gt;六、单个 TTL 重试队列的限制：混合延迟会出现队头阻塞
&lt;/h2&gt;&lt;p&gt;重试链路跑通之后，一个自然的想法是让间隔递增：1 秒、10 秒、60 秒，给下游留出恢复时间。这个想法在 RabbitMQ 的 TTL 上会撞到一件不那么直观的事。&lt;/p&gt;
&lt;p&gt;队列级的 &lt;code&gt;x-message-ttl&lt;/code&gt; 是队列的属性，同一条队列里的消息共用一个延迟，所以它天然只能做固定间隔，要递增就得给每一级配一条队列。另一条路是 per-message TTL：投递时给每条消息带上自己的 &lt;code&gt;expiration&lt;/code&gt;。直觉上「谁先到期谁先走」，实测是「队头说了算」。&lt;/p&gt;
&lt;p&gt;同一条队列投 3 条消息、各带不同的 &lt;code&gt;expiration&lt;/code&gt;，只换入队顺序（脚本 &lt;code&gt;ttl-head-of-line-blocking.py&lt;/code&gt;）：先投 6000ms 再投两条 1000ms，三条全部在 +6.0s 才死信；把顺序换成两条 1000ms 在前、6000ms 在后，前两条 +1.0s 就走，第三条 +6.0s。后两条的 &lt;code&gt;expiration&lt;/code&gt; 明明只有 1000ms，却等到 +6.0s 才死信，被队头那条 6000ms 的消息卡了约 5 秒。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;在本实验这种「同一条 classic 队列、只靠 per-message TTL 触发死信」的场景下，过期检查只在队头进行。&lt;/strong&gt; 队头那条没到期，排在它后面、已经过期的消息也不会被处理。&lt;/p&gt;
&lt;p&gt;官方文档写得很清楚：classic queue 与 quorum queue 都是「过期消息到达队头时才被死信掉」，并且「设置 per-message TTL 后，已过期的消息可能排在未过期消息后面，直到后者被消费或过期；这些过期消息占用的资源不会被释放，也会被计入队列的消息数」。&lt;/p&gt;
&lt;p&gt;所以递增退避的「手搓」做法有两条：每一级一条队列（1s 队列、10s 队列、60s 队列各配一条，用路由键区分级别），或者装延迟消息插件。本 demo 用固定 2000ms 间隔，正是因为它只需要一条重试队列。&lt;/p&gt;
&lt;p&gt;不过这两条都不是唯一的答案了。RabbitMQ 4.3 起给 quorum queue 提供了原生 delayed retry：通过 &lt;code&gt;x-delayed-retry-type&lt;/code&gt; / &lt;code&gt;x-delayed-retry-min&lt;/code&gt; / &lt;code&gt;x-delayed-retry-max&lt;/code&gt; 三个参数，消息被拒绝（reject/nack）后按投递次数自动线性退避，延迟 = &lt;code&gt;min(min_delay × delivery_count, max_delay)&lt;/code&gt;。它把「递增退避」做成了队列的内建能力，不用再叠一堆 TTL 队列或插件。注意三点：它只对 quorum queue 生效；默认 &lt;code&gt;x-delayed-retry-type=disabled&lt;/code&gt;，要显式开启；本 demo 用的是 classic queue + 单条 TTL 重试队列，走的是上面那条「手搓」路径，正好能看清队头阻塞这个底层限制。&lt;/p&gt;
&lt;h2 id="七一致性补偿broker-之外的那一段"&gt;七、一致性补偿：Broker 之外的那一段
&lt;/h2&gt;&lt;p&gt;上面讲的都是「消息发出去之后」的可靠性。还有一类问题在更早的地方：&lt;strong&gt;订单写进了数据库，消息没发出去，或者反过来。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在 RabbitMQ 上这件事要靠应用层补偿。AMQP 的 channel 事务在这里帮不上忙，它的硬伤首先是&lt;strong&gt;事务边界&lt;/strong&gt;：channel 事务只能包住「发消息」这一个动作，包不住「数据库写 + 发消息」这对跨系统的操作，所以它根本解决不了原子提交；顺带它还有吞吐代价（文档给的是降到约 1/250）且和 confirm 模式互斥。RabbitMQ 也没有 RocketMQ 那种 broker 级的事务消息（半消息 + 回查）。可行的模式是&lt;strong&gt;本地消息表&lt;/strong&gt;（也叫 outbox）：在写订单的同一个本地事务里，往一张 outbox 表插一条待投递记录；事务提交后由后台任务把 outbox 里的记录投递出去，投递成功再标记完成；消费端仍然靠幂等兜底。&lt;/p&gt;
&lt;p&gt;这个模式的关键在「同一个本地事务」，数据库事务保证订单和待发消息一起落库，消息投递则从「必须成功」降级成了「迟早成功」。&lt;strong&gt;本 demo 没有实现本地消息表&lt;/strong&gt;，这里只说明它在链路里的位置：它是把「改库 + 发消息」这对跨系统操作，压回一个本地事务里的做法。&lt;/p&gt;
&lt;p&gt;三个角色可以分开看：订单表和 outbox 表写在同一个事务里，解决「改库成功但消息没发出去」；后台投递任务负责重试，解决「投递时下游不可用」；消费端幂等兜最后一道，解决「投递重试带来的重复」。三处缺一处，一致性就漏一处。&lt;/p&gt;
&lt;p&gt;这个模式也有自己的代价：outbox 表需要清理策略，否则它会跟着业务表一起无限增长；投递任务要独立控制并发和失败告警，它本身变成一条需要被监控的链路；投递逻辑和业务表放在同一个库时，事务的粒度会变大。它适合「本地写库、且要求通知最终一定送到」的场景，不适合拿来兜一个本来就不要求一致的通知。&lt;/p&gt;
&lt;h2 id="八boot-4-与-rabbitmq-43-上的几处版本差异"&gt;八、Boot 4 与 RabbitMQ 4.3 上的几处版本差异
&lt;/h2&gt;&lt;p&gt;这套代码跑在 Spring Boot 4.1.1 + Spring AMQP 4.1.1 + amqp-client 5.30.0 + JDK 21 上，和大多数 Boot 3 时代的 RabbitMQ 教程有几处对不上的地方：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;现象&lt;/th&gt;
					&lt;th&gt;原因&lt;/th&gt;
					&lt;th&gt;处理&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;Jackson2JsonMessageConverter&lt;/code&gt; 找不到依赖&lt;/td&gt;
					&lt;td&gt;Boot 4 用 Jackson 3（&lt;code&gt;tools.jackson&lt;/code&gt;），类路径上只有 &lt;code&gt;com.fasterxml.jackson&lt;/code&gt; 的注解包，没有 databind&lt;/td&gt;
					&lt;td&gt;改用 &lt;code&gt;JacksonJsonMessageConverter&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;消费侧抛 &lt;code&gt;The class ... is not in the trusted packages&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;转换器默认只信任 &lt;code&gt;java.util&lt;/code&gt; / &lt;code&gt;java.lang&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;按消息类的包名授权，不要写成 &lt;code&gt;&amp;quot;*&amp;quot;&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;Confirm.isAck()&lt;/code&gt; 报弃用&lt;/td&gt;
					&lt;td&gt;Spring AMQP 4 起确认结果改成了 record&lt;/td&gt;
					&lt;td&gt;用 &lt;code&gt;ack()&lt;/code&gt; / &lt;code&gt;reason()&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;死信监听器反序列化失败&lt;/td&gt;
					&lt;td&gt;参数写成 &lt;code&gt;org.springframework.amqp.core.Message&lt;/code&gt; 会走 typeId 反推，撞上信任包检查&lt;/td&gt;
					&lt;td&gt;参数直接用具体的消息类型&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;改完队列参数后应用启动失败，报 &lt;code&gt;inequivalent arg&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;队列的 &lt;code&gt;x-dead-letter-exchange&lt;/code&gt; 等参数不允许原地修改&lt;/td&gt;
					&lt;td&gt;先 &lt;code&gt;rabbitmqctl delete_queue&lt;/code&gt; 再重启，让应用重新声明（只适用于可清空的实验环境，删除队列会连消息一起删；生产环境应改用 policy 动态配置）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;脚本里声明非持久化的非排他队列报 &lt;code&gt;transient_nonexcl_queues is deprecated&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;RabbitMQ 4.3 起默认不再允许这类队列&lt;/td&gt;
					&lt;td&gt;一律声明为持久化队列&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;最后一条是 4.3 引入的：官方文档把「transient（非持久化）且非排他的 classic queue」标为弃用，4.3.0 起默认不允许声明。写实验脚本时如果沿用了老教程里的 &lt;code&gt;durable=False&lt;/code&gt;，会直接撞上这个报错。&lt;/p&gt;
&lt;h2 id="九收口三段各兜住什么以及它划出的边界"&gt;九、收口：三段各兜住什么，以及它划出的边界
&lt;/h2&gt;&lt;p&gt;回到开篇那三个可能消失的位置，每一处现在都能对上兜底的一方，也能看清它兜到哪为止：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;发送时&lt;/strong&gt;：生产端的 confirm 加 return。两道回执都要看——confirm 跟着路由结果走，不可路由时它照样返回 ack，只监听 confirm 等于在发送环节装一盏常亮的绿灯；回执也覆盖不到订阅配置，没有 return 只说明至少有一个队列接收了。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;存储时&lt;/strong&gt;：Broker 端的持久化与副本。两个持久化属性回答「重启后还在不在」，副本回答「机器坏了还在不在」，后者把可用性前提从「节点在线」换成「多数派在线」，故障期间连读队列视图都会超时。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;消费时&lt;/strong&gt;：消费端的 ack 时机与 prefetch。前者决定崩溃时消息是重投还是丢失，后者决定重投的规模，但都没有改变「可能重复」这个结果。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;重复这件事在三段里没有归宿：Broker 侧的 &lt;code&gt;x-delivery-count&lt;/code&gt; 与 &lt;code&gt;delivery-limit&lt;/code&gt; 管的是毒消息别在队列里无限打转，不是业务代码只跑一次；消掉重复的位置在消费端的业务幂等里，而幂等只覆盖它自己管得住的那几个副作用。&lt;strong&gt;三段拼出来的承诺是「不丢」加 at-least-once，不是「既不丢也不重」&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;代价和上面三处一一对应：生产端要处理两道回执，并决定同步等还是异步收，同步等会把发送方拖进回执时延里（文档给的量级是持续负载下数百毫秒）；Broker 端要接受「多数派不在就拒绝写入」这个前提；消费端先处理再 ack 把重复引了进来，扩容之后同一笔业务落到两个消费者手里会更常见；重试链路上固定间隔只占一条 TTL 队列，改成递增退避，要么每级一条队列，要么接受过期检查只在队头这一条限制。&lt;/p&gt;
&lt;p&gt;还有一段不在这三段的视野里：订单写进数据库、消息没发出去（或者反过来）发生在第七节的位置。三段兜底保证消息不消失，保证不了业务状态和消息一起生效；把这件事压回一个本地事务里的做法是本地消息表，它的代价是 outbox 的清理策略、投递链路自己的监控，以及变大的事务粒度。&lt;/p&gt;
&lt;h2 id="参考资料"&gt;参考资料
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/confirms" target="_blank" rel="noopener"
 &gt;Consumer Acknowledgements and Publisher Confirms&lt;/a&gt;：publisher confirm 与 consumer ack 的定义与触发时机，mandatory 与 &lt;code&gt;basic.return&lt;/code&gt; 的先后顺序。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/reliability" target="_blank" rel="noopener"
 &gt;Reliability Guide&lt;/a&gt;：at-least-once / at-most-once 的边界，以及消费端去重与幂等的必要性。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/queues" target="_blank" rel="noopener"
 &gt;Queues&lt;/a&gt;：队列持久性与消息持久化的关系，transient 非排他 classic queue 的弃用说明。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/quorum-queues" target="_blank" rel="noopener"
 &gt;Quorum Queues&lt;/a&gt;：Raft 副本、多数派可用性、publisher confirm 的发出时机、&lt;code&gt;x-delivery-count&lt;/code&gt; 与 &lt;code&gt;delivery-limit&lt;/code&gt;、at-least-once 死信（&lt;code&gt;dead-letter-strategy&lt;/code&gt;/&lt;code&gt;overflow&lt;/code&gt;）、4.3 原生 delayed retry。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/dlx" target="_blank" rel="noopener"
 &gt;Dead Letter Exchanges&lt;/a&gt;：死信的四种触发条件，&lt;code&gt;x-death&lt;/code&gt; 头部结构与按 &lt;code&gt;{队列, 原因}&lt;/code&gt; 压缩的规则。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/ttl" target="_blank" rel="noopener"
 &gt;Time-To-Live and Expiration&lt;/a&gt;：per-queue 与 per-message TTL 的区别，过期检查发生在队头的行为。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/consumer-prefetch" target="_blank" rel="noopener"
 &gt;Consumer Prefetch&lt;/a&gt;：&lt;code&gt;basic.qos&lt;/code&gt; 与未确认消息上限。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/metadata-store" target="_blank" rel="noopener"
 &gt;Metadata store&lt;/a&gt;：Khepri 自 4.2.0 起成为默认元数据存储，与 Mnesia 的关系。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;演示代码&lt;/strong&gt;：完整实现在 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/c02-mq-reliable-delivery" target="_blank" rel="noopener"
 &gt;GYD 仓库 &lt;code&gt;c02-mq-reliable-delivery&lt;/code&gt;&lt;/a&gt;，该目录的 README 列了环境、运行方式、端点与本文各节的对照，以及本文引用的完整输出记录；RabbitMQ 与 3 节点集群的编排在仓库级 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/infra" target="_blank" rel="noopener"
 &gt;&lt;code&gt;infra/&lt;/code&gt;&lt;/a&gt;，两个实测脚本在 &lt;code&gt;infra/rabbitmq/scripts/&lt;/code&gt; 与 &lt;code&gt;infra/rabbitmq-cluster/scripts/&lt;/code&gt;。&lt;/p&gt;

 &lt;/blockquote&gt;</description></item></channel></rss>