1. 先搞清楚 LCN 这套框架到底在解决什么问题
1.1 一个把老系统拆成微服务之后必然遇到的场景
先把问题摆出来。假设你手上有三个服务:订单、库存、账户。用户下单这条链路,订单服务要写一条订单记录,库存服务要扣减一个数量,账户服务要冻结一笔金额。单库单应用的时代,这三步就是一个本地事务,数据库的原子性帮你兜住了一切,失败就 rollback,完事。
拆成微服务之后,这三个操作处在三个不同的数据源、三个不同的进程里,各自有各自的连接和事务管理器。订单服务的事务管不到库存服务的连接,库存服务的回滚也不会自动触发账户服务的撤销。更要命的是,这条链路里任何一环超时、网络抖动、服务重启,都可能让数据处在一种"订单成了、库存扣了、钱没冻结"的半吊子状态。
分布式事务框架就是冲这件事来的。LCN 是其中一种实现路径,它的定位很明确:让开发者尽量不改业务代码,用几个注解就能把跨服务的数据一致性管起来。这篇内容适合三类人看:正在技术选型、拿不定主意的架构师;已经用了 LCN、但线上出过事想搞明白原理的同学;以及纯粹想知道"分布式事务到底能不能真的做好"的一线开发。我不敢说讲得多权威,但下面这些都是我在实际项目里趟出来的东西。
1.2 名字拆开看,三段式设计其实很好懂
LCN 这三个字母不是随便拼的,它对应框架里三个核心动作:
| 字母 | 全称含义 | 在框架里对应什么动作 |
|---|---|---|
| L | Lock,锁定事务单元 | 事务参与者执行本地 SQL 时,通过锁表把涉及的数据行锁住,防止别的全局事务来改 |
| C | Confirm,确认事务模块状态 | 各参与方把自己本地事务的执行结果上报给协调者,形成一个事务组的状态汇总 |
| N | Notify,通知事务单元 | 协调者根据事务组整体状态,反向通知每一个参与者进行真正的提交或回滚 |
这个顺序不是随便排的。它的设计前提是:本地事务先"假装"执行完但不落库,等全局结果明确了再统一收口。你可以把它类比成一场餐厅结账:每个人先把自己的账单记在桌上(本地事务执行但挂起),服务员汇总所有人吃完没有(确认状态),最后统一决定是买单还是这桌撤单(通知提交或回滚)。
这个思路最大的好处是对业务代码侵入极小。你写业务逻辑还是写原来的 SQL、原来的 Service 方法,框架在数据源和切面这一层就把事情截住了。代价也很明显——它把一致性责任压到了连接管理和锁表上,这就决定了后面所有坑的来源。
1.3 必须提前说清的边界:它不是银弹
很多人第一次接触 LCN,会误以为它能做到和单库事务完全一样的强一致。不是的。LCN 模式本质上是一种最终一致性 + 局部锁定的方案,它给出来的保证是"最终大家会达成一致",只是在这个过程中用锁表尽量让别人的脏读变得困难。
它扛不住的情况我心里有本账:协调者所在机器彻底没了、事务组状态丢了,那就只能靠补偿日志表来人工或者定时兜底;参与者执行到一半进程被 kill,挂起的连接直接断开,本地事务会被数据库按超时回滚,但参与方已经上报了"成功",这时就得靠框架的异常记录来核对。如果你的业务场景要求的是"任何时刻查到的都是全局一致的数据",那 LCN 不适合,你该去考虑更重的方案甚至退回到单库设计。但如果你的场景是典型的电商下单、支付流水、积分发放这类可以容忍秒级最终一致的业务,那它足够用,而且落地成本低。
2. 整体架构拆解:一次分布式事务是怎么跑起来的
2.1 协调者 TxManager 的角色与它的"轻"与"重"
整套体系里有两个角色:一个是独立部署的协调者(下面统一叫事务协调器),另一个是以 jar 包形式嵌在业务服务里的事务参与者。
协调器本身很轻,它不碰你的业务库,只做三件事:维护事务组、接收状态上报、发出最终通知。它需要一个自己的数据存储来记录事务组信息和异常记录,通常就是一张事务组表加一张异常补偿表。它通过注册中心暴露自己,业务服务启动时把自己的地址报上去,形成"协调器 → 参与者列表"的映射关系。
它轻的地方在于逻辑,重的地方在于它是整条链路的单点。协调器挂了,正在跑的事务会悬在半空——参与者那边连接已经挂起了,但没人来通知是提交还是回滚。所以生产上至少要做两件事:协调器集群部署(多实例共享同一个存储),以及给参与者侧配好超时兜底,让挂起的连接不会无限期占着。
2.2 参与者 TxClient 的植入方式:三个切面口子
参与者的活儿多一点,它要在业务服务里切三个口子:
- 入口切面:拦截被事务注解标记的方法,判断自己是发起方还是参与方。发起方要创建事务组,参与方要从请求里取出事务组 ID 加入组。
- 数据源代理:把业务数据源包一层,返回的 Connection 是代理对象,SQL 执行完不真正提交,close 的时候也不真正关闭。
- 远程调用切面:在发起远程调用前把事务组 ID 塞到请求上下文,保证下游服务能接着用。
这三层是层层咬合的。入口切面决定了这次调用要不要进事务组,数据源代理决定了本地事务怎么被"挂起",远程切面决定了事务组 ID 能不能跨进程传下去。任何一环断了,结果就是"部分服务参与了、部分没参与",这种半参与状态是最难查的,后面排查章节我会专门说。
2.3 事务组 ID 的生成、透传与生命周期
事务组 ID 是整条链路的身份证。发起方一进来就找协调器要一个新的组 ID,然后这个 ID 要像接力棒一样传下去。传的方式主流有两种:通过 RPC 框架的隐式参数(类似 Dubbo 的 attachment)或者 HTTP 请求头。框架通常会把它放在一个 ThreadLocal 的上下文对象里,远程调用切面从上下文取出来塞进请求。
这里有个新手极容易踩的点:ThreadLocal 天然不跨线程。你在业务代码里开了个线程池去做并行调用,或者用了异步编排,事务组 ID 就丢了,下游服务收到的请求里没有这个 ID,它会认为自己是发起方,重新创建一个新的事务组。结果就是两个互不相干的事务组各跑各的,谁也不知道对方的存在。我在一个项目里就因为这个丢过数据——表面上看日志一切正常,实际是两笔独立的本地事务,一笔成了一笔败了,没有任何人发现。
事务组的生命周期跟着发起方的入口方法走:方法开始创建,方法结束(无论成功失败)通知协调器收口,协调器再分发通知,最后清理。中间任何环节超时,都靠配置的等待时间和重试次数来兜。这个"兜"的过程,就是后面第 4 章要算参数的依据。
3. 核心原理深挖:连接挂起和锁表这两把刷子
3.1 数据源代理与"假关闭":不提交也不关连接
这是 LCN 模式最核心、也最反直觉的一招。正常情况下,一个@Transactional方法执行完,Spring 的事务管理器会调commit(),然后连接归还池子。LCN 把这条路截断了:它代理出来的连接,commit()被拦下不执行,close()也不真正归还,而是把这个物理连接放进一个缓存里挂着(框架内部叫连接持有),等协调者的最终通知来了,再从缓存里取出来真正commit或者rollback。
打个比方,这就像你去银行办业务,柜员把你的材料收进去说"先放着",人没走、号没消、窗口没关,等后台审批结果出来再告诉你是办成还是退回。窗口一直被占着,这就是为什么 LCN 对连接池的压力比普通事务大得多——一个全局事务从开始到结束的整个时间段,参与方都要额外占着一个物理连接。
这个设计的收益是巨大的:业务代码完全不用改,SQL 照写,事务注解照用,框架在底层偷梁换柱。代价是它对连接的依赖极强,一旦最终通知没到、或者流程异常导致连接没被释放,连接池很快会被耗干,表现出来的现象就是服务整体卡死,所有请求超时。这个坑我在第 5 章会展开讲。
注意:正因为连接被长时间占用,参与方的数据源最大连接数不能按平时的经验值来配。如果你的服务 QPS 高、事务链路长,最小连接数和最大连接数都要留出余量,否则高峰期会因为等连接而排队。
3.2 锁表是怎么顶替全局锁的
光挂起连接还不够。假设 A 事务正在改订单 1001 这条记录(还没提交),B 事务也来改 1001,数据库层面因为 A 的本地事务没提交,B 的更新会被行锁挡住——这在同一个库上没问题。但如果 B 是通过另一个服务、另一个数据源来改的呢?行锁就管不住了。
LCN 的解法是引入一张锁表。参与方在执行写操作之前,先解析 SQL 拿到涉及的主键,去锁表里插入一条记录(事务组 ID + 表名 + 主键值)。插入成功说明这把锁归你了;插入冲突说明别人正在改,就按配置的重试次数和间隔等待重试,重试耗尽就抛异常回滚。
这套机制的巧妙之处在于:它把数据库的行锁语义,"搬"到了应用层的一张通用表上,从而实现了跨数据源、跨服务的互斥判断。但它也有明显的短板。第一,它只认识主键,如果你的更新条件不是主键(比如update ... where status = 1),解析不出主键就没法加锁,等于裸奔。第二,每次写操作都多一次锁表插入和一次删除,性能开销实打实存在,高并发写入场景会明显感觉到。第三,锁表的清理依赖事务正常收口,如果某个事务异常终止没清理干净,残留的锁记录会一直挡住后来的事务,需要人工介入排查。
我在实际项目里给团队定过一条规矩:凡是走 LCN 模式的写操作,SQL 必须带主键条件,不允许用范围更新。这条规矩听着粗暴,但它把最难排查的一类问题直接消灭在了编码阶段。
3.3 切面顺序与事务上下文的线程模型
切面顺序这个点,文档里往往一句话带过,但它决定了很多诡异问题的走向。业务方法上通常同时挂着框架的事务注解和 Spring 的本地事务注解。两个切面谁先执行?如果框架切面在内层,那就是先开本地事务再进全局事务组,本地事务提交时会被代理拦下;如果顺序反了,本地事务可能还没来得及开就被挂起了。
框架一般通过实现Ordered接口来控制优先级,让全局事务切面在最外层包住本地事务切面。这个顺序配错的表现是:事务看着跑了,但连接根本没被挂起,等于 LCN 完全没起作用,回滚时参与者那边数据已经落库了,怎么都对不上。
线程模型这块,除了前面说的 ThreadLocal 丢失,还有一个隐蔽的坑:Spring 的@Async或者自定义线程池里执行数据库操作,代理连接是拿不到的,因为连接绑定在原来那个线程的事务上下文上。新线程里拿到的可能是池子里另一条普通连接,SQL 直接提交了。所以走 LCN 的事务方法里,尽量别开异步分支,非开不可的话,要显式把事务上下文传递过去,或者干脆把这个子操作设计成幂等可补偿的独立动作。
4. 动手落地:从零搭一套 LCN 事务链路
4.1 协调器服务部署与关键配置
协调器是一个独立的服务,部署方式和普通服务没差别。它的核心配置项大致是这几类:服务端口、注册中心地址、自身的数据源、以及事务相关的默认参数。下面这份配置是示意,字段名以你实际使用的分支文档为准,我刻意没写死版本。
server: port: 7970 # 协调器默认端口,冲突的话改成别的 spring: application: name: tx-manager # 参与者要按这个名字去发现它 datasource: url: jdbc:mysql://127.0.0.1:3306/tx_manager?useUnicode=true&characterEncoding=utf8 username: tx_user password: ****** driver-class-name: com.mysql.cj.jdbc.Driver # 注册中心配置按你所在环境的组件来写 tx-lcn: manager: port: 7971 # 协调器内部通信端口 heart-time: 15000 # 心跳间隔,单位毫秒 durl: 127.0.0.1 # 对外暴露地址,集群下要写实际可达地址建库的时候要把框架自带的建表脚本跑一遍,至少包含事务组表和异常记录表。事务组表存的是每次全局事务的状态和参与方列表,异常表存的是最终通知失败后留下的待补偿记录。这两张表的清理策略一定要配,我见过跑了一年多、事务组表几千万行没人管的库,查一次状态慢到超时,反过来又把事务拖挂了。
4.2 参与者侧的数据源代理配置
参与者这一侧的配置比协调器更细致,因为要接管数据源。这里最容易出问题的是数据源包装的顺序:代理必须包在真正的连接池外面,而不是反过来。
tx-lcn: client: manager-address: 127.0.0.1:7970 # 协调器地址,集群写多个 tx-group: business-group # 事务分组,跨服务要一致 max-wait-time: 6 # 等待最终通知的最大秒数 heart-time: 15000 chain-level: 3 # 最大事务链路层级,按调用深度调数据源的包装一般用框架提供的工厂类来做,配合连接池组件。用代码示意大概是这个形态:
@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties(prefix = "spring.datasource") public DataSource rawDataSource() { // 这里是真正的连接池,比如常用的高性能池子 return new com.zaxxer.hikari.HikariDataSource(); } @Bean @Primary public DataSource txDataSource(DataSource rawDataSource) { // 必须包一层,否则事务挂不住 return new LcnDataSourceProxy(rawDataSource); } }注意:很多人漏了
@Primary注解,项目里存在多个数据源 Bean 时,Spring 注入的可能是没被代理的那个原始数据源。表现就是注解加了、事务看着跑了、回滚不生效。这类问题排查起来非常费时间,因为日志完全正常,只有对着 Bean 名字看才能发现。
4.3 发起方的编码注解与事务组传递
发起方的代码写起来最舒服,就是在方法上加注解。以一个下单场景为例:
@Service public class OrderService { @LcnTransaction(timeout = 30) public void createOrder(OrderDTO dto) { // 1. 本地写订单,走代理数据源,不真正提交 orderMapper.insert(buildOrder(dto)); // 2. 远程调库存,事务组 ID 由切面自动透传 inventoryClient.deduct(dto.getSkuId(), dto.getQty()); // 3. 远程调账户,同样自动带上事务组 accountClient.freeze(dto.getUserId(), dto.getAmount()); // 到这里如果都没抛异常,切面会通知协调器统一提交 // 任何一步抛异常,切面会通知协调器统一回滚 } }参与方那边只需要在方法上加同样的注解,不需要做任何额外的事情,切面会自动从请求里捞事务组 ID 加入组,并且把本地事务的执行结果上报。如果参与方用的是 TCC 模式,则需要显式写 try / confirm / cancel 三个方法,并在注解上指明对应的 confirm 和 cancel 方法名。
@Service public class InventoryService { @TccTransaction(confirmMethod = "confirmDeduct", cancelMethod = "cancelDeduct") public void deduct(String skuId, int qty) { // try 阶段:只做资源检查与预占,不动真实余额 inventoryMapper.reserve(skuId, qty); } public void confirmDeduct(String skuId, int qty) { // 真正扣减,必须幂等 inventoryMapper.commitReserve(skuId, qty); } public void cancelDeduct(String skuId, int qty) { // 释放预占,必须幂等,且要处理空回滚 inventoryMapper.releaseReserve(skuId, qty); } }TCC 模式下 confirm 和 cancel 一定要做幂等,因为我们没法保证协调器只通知一次。同时 cancel 还要能处理"try 根本没执行过就来 cancel"的空回滚场景,否则一释放就是负库存。
4.4 超时、链路层级、重试这几个参数怎么定
参数不能拍脑袋,我给一套我自己在用的算法。
等待时间:这个值决定了参与者挂起的连接最多等多久。它的下限应该是"整条链路最慢那个参与方法的耗时",上限取决于你的连接池余量。我的经验公式是:
最大等待时间 ≈ 链路 P99 耗时 × 1.5比如整条下单链路 P99 是 800ms,那等待时间设 2~3 秒是合理的。设得太小,正常慢请求会被误判超时回滚,用户看到的是莫名其妙的失败;设得太大,一旦真出事,连接被占住的时间也同步变长。
链路层级:框架需要知道最多允许多深的调用链,配合事务组 ID 的透传层数。这个值设小了,深层的服务不会被纳入事务组。算法的思路是:
链路层级 = 最长调用路径上的服务数量 + 1(安全余量)三个服务串行调用,设 3 或 4 都行,别设 1。
重试次数:包括锁表冲突重试和最终通知重试。锁表重试我一般设 3~5 次、间隔 100~300ms,兼顾冲突消化和响应时间。最终通知重试属于兜底性质,重试几次仍然失败就走异常记录表,等定时任务或人工补偿。
| 参数 | 建议取值 | 判断依据 | 设错的后果 |
|---|---|---|---|
| 最大等待时间 | 链路 P99 × 1.5 | 连接池余量、业务耗时 | 偏小误回滚,偏大连接耗尽 |
| 链路层级 | 最长路径服务数 + 1 | 实际调用深度 | 偏小导致下游未入组 |
| 锁表重试次数 | 3~5 次 | 业务冲突概率 | 偏小高并发下失败率上升 |
| 心跳间隔 | 10~15 秒 | 网络质量与协调器负载 | 偏大故障发现慢,偏小增加负载 |
| 发起方超时 | 略大于等待时间 | 端到端容忍度 | 偏小导致还没收口就断开 |
5. 排查实录:那些线上翻车的时刻
5.1 事务没回滚,先确认事务组有没有拉齐
数据不一致的投诉上来,我的第一反应不是去看回滚逻辑,而是去确认这次调用到底有几个参与者进了组。协调器那边能看到事务组下挂的参与者列表,这个列表就是账本。如果本该三个参与者,实际只有两个,那结论很清楚:有一个服务的调用根本没被纳入事务,它的本地事务已经真实提交了,回滚时自然管不到它。
排查方向按这个顺序走:先看发起方调用下游时事务组 ID 有没有带出去(抓包或者看请求头),再看下游服务的入口切面有没有触发(日志里找事务组 ID 的打印),最后看下游的数据源是不是被代理了(Bean 注入检查)。这三步能覆盖九成以上的"事务没生效"问题。
还有一种更阴的情况:所有服务都进组了,但某个参与者的本地事务提前提交了。这基本就是数据源代理没生效,或者方法上同时有多个事务注解导致切面顺序乱了。判断方法很简单:在参与者方法里查一下当前连接对象是不是代理类型,不是就说明挂起根本没发生。
5.2 连接被"假关闭"之后,池子是怎么被打满的
线上现象很典型:服务响应时间持续爬升,最后所有请求超时,重启后恢复正常,过一段时间又来一次。看指标会发现活跃连接数一直顶在最大值下不来。
根因通常在这几处:一是最终通知没送达(协调器重启、网络分区、参与者地址变了),挂起的连接没人来收口;二是发起方超时时间比参与方的等待时间短,发起方先断了,参与者还在傻等;三是业务代码里有 try-catch 吞掉了异常,切面拿不到失败信号,以为一切正常,可协调器那边事务组状态又不完整。
对策我总结了几条。一是把等待时间、发起方超时、连接池的maxLifetime三者的关系理顺:发起方超时 > 参与者等待时间 > 正常业务耗时,形成梯次,避免互相踩。二是给连接池加上泄漏检测,超过阈值时打日志并强制回收,先把服务救活再说。三是对协调器做健康检查,参与者发现协调器不可达时,宁可快速失败也不要傻等。
5.3 锁表冲突、死锁与重试风暴
锁表冲突的报错会以"数据已被锁定"之类的形式冒出来。少量冲突是正常的,说明机制在起作用;但如果错误量突然放大,就要看是不是某个长事务一直没结束,把一把锁攥了很久。
我遇到过最典型的一次是:某个参与方的接口内部又调了一个第三方 HTTP 接口,那个接口平均响应 6 秒。整条链路就被这一个慢点拖着,其他事务排队等锁,重试次数一叠加,QPS 不高的服务硬生生被打成了雪崩。这件事教会我一个原则:走 LCN 事务的方法里,绝对不要放不可控的外部调用。外部调用要么挪到事务外面,要么改成异步补偿。
另一个死锁来源是多个事务加锁顺序不一致。A 事务先锁订单表再锁库存表,B 事务反过来。解法就一个笨办法但有效:给所有涉及的表定一个固定的加锁顺序,全局统一遵守。或者在业务上做拆分,让这两类操作根本不在同一条链路上。
5.4 常见问题速查表
| 现象 | 最可能的原因 | 快速验证方法 | 处理动作 |
|---|---|---|---|
| 参与者没回滚 | 数据源未代理 | 检查注入的 DataSource 类型 | 加代理包装与@Primary |
| 事务组里少了服务 | 事务组 ID 未透传 | 抓请求头看有没有组 ID | 补远程调用拦截器 |
| 连接池持续打满 | 挂起连接未收口 | 看活跃连接曲线与协调器日志 | 调超时梯次,加泄漏检测 |
| 报数据已被锁定 | 锁表残留或长事务 | 查锁表记录的时间分布 | 清理残留,剔除慢调用 |
| 重复扣减 | 未做幂等 | 查同一请求的执行次数 | confirm/cancel 加去重 |
| 空回滚出现负值 | cancel 未判空 | 查 cancel 时的 try 记录 | 加状态判断,无记录直接返回 |
| 启动报连不上协调器 | 地址或分组不一致 | 看双方配置与注册中心 | 对齐地址和分组名 |
提示:出问题的时候,别急着翻框架源码。先把这次事务的组 ID 拿出来,顺着日志把整条链路串一遍——发起方什么时候建的组、每个参与者什么时候入的组、上报了什么状态、最终通知什么时候发的。这一条时间线捋清楚了,问题基本就定位了八成。
6. 选型与演进:什么时候用它,什么时候别硬上
6.1 几种主流思路的横向对比
分布式事务这个领域,业内公认没有完美解法,只有适配。我把常见的几种路径摆在一起做个对照,都是按我自己的理解整理的,具体实现细节各家会有出入。
| 维度 | LCN 模式 | TCC 模式 | 基于 undo log 的补偿模式 | 消息最终一致 |
|---|---|---|---|---|
| 业务侵入 | 极低,加注解即可 | 高,要写三个方法 | 低,但需维护回滚日志 | 中,要设计消息与幂等 |
| 一致性强度 | 最终一致 + 局部锁定 | 最终一致 | 最终一致 | 最终一致 |
| 隔离性 | 靠锁表,粒度粗 | 靠资源预占,粒度细 | 靠全局锁 | 无,读未提交风险 |
| 性能开销 | 中,连接占用明显 | 中高,多两次调用 | 中,日志写入开销 | 低 |
| 适用场景 | 内部服务、链路不长的写操作 | 金融类、需要资源预留 | 已有 ORM 体系的存量项目 | 异步链路、通知类业务 |
| 运维复杂度 | 中,协调器需高可用 | 高,幂等与空回滚要设计 | 中高 | 低但需消息中间件保障 |
我的判断标准很朴素:链路短、团队小、要快,选 LCN 模式;链路里有资金类操作、要求资源可见,选 TCC;链路特别长、跨团队协作多,考虑消息最终一致加上对账兜底。不要为了"技术先进"上一套自己都运营不了的方案,那比不做分布式事务更危险。
6.2 生产环境的几条硬性约束
第一,协调器绝对不能单实例。它是全局单点,挂了就是整条业务链路的雪崩。多实例部署加共享存储,配合健康检查,这是底线。
第二,异常记录表要有定时的巡检任务。不是所有失败都能自动补偿,有些必须人来判断。我的习惯是每天扫一遍前一天的异常记录,超过阈值就报警,让值班的人当天就处理掉。攒着不管,一个月后就是一本糊涂账。
第三,业务侧一定要有对账兜底。这个和框架无关,是工程纪律。不管你用多好的分布式事务方案,都要有一份定时的对账逻辑去核对核心数据(订单与流水、库存与出库记录)。框架是降低概率的,不是消除风险的。真正救过我们一次的不是事务框架,而是一个每晚跑的差异校验任务,它发现了三笔因为协调器重启而卡住的事务。
第四,链路深度要控制。三到五个服务的链路,LCN 跑得挺舒服;十几个服务串下来,超时、重试、连接占用这些问题会成倍放大。如果发现自己的事务链路已经长得离谱,正确的做法不是加大超时时间,而是拆链路——把能异步的环节拿出去,把能提前确定的步骤前置。
我在几个项目里踩过的坑,最后都指向同一个结论:分布式事务框架解决的是"技术上的原子性",但真正让数据一致的,是"业务上把边界画清楚"。哪些操作必须在一个事务里,哪些可以事后补偿,这个判断做对了,框架只是顺手用一下;这个判断做错了,上什么框架都白搭。