前阵子做一个国产化改造项目,数据库从原来的商业数据库切到达梦DM8,应用层是Spring Cloud微服务,订单服务和库存服务各连一套库。切换本身倒还好,真正让人崩溃的是分布式事务——订单服务写成功了,库存服务返回超时,排查完发现库存其实扣了,但订单那边已经回滚,两边数据对不齐。后来我把Seata AT模式在DM8上完整跑通,核心工作其实是源码适配:达梦不在Seata官方支持的数据库列表里,通过改源码让Seata的RM端(资源管理器)认识达梦的方言、元数据和undo_log读写。这篇文章把我从环境准备、源码修改到最终验证的完整过程整理出来,适合正在用达梦做微服务、又必须解决分布式事务的团队。
1. 订单扣库存炸了:分布式事务故障现场与AT模式的解题思路
1.1 从一次数据不一致说起
场景一点都不复杂:用户下单一件事,涉及两个服务。order-service负责写订单表,inventory-service负责扣库存。正常流程是先写订单,再远程调用扣库存。问题出在远程调用失败的那一刻——订单已经提交到本地数据库,库存没扣成功,用户重新下单又扣了一次,或者反过来说,订单回滚了但库存已经扣了,两边就是永远对不上。
传统的解决思路里,最“正统”的是XA协议。数据库原生支持两阶段提交,业务通过TransactionManager开启全局事务,所有参与者要么一起提交,要么一起回滚。这套方案在单库时代没问题,但微服务场景下用XA会遇到几个实际问题:一是XA事务对连接持有时间长,数据库的锁和连接池压力都很大,高并发根本扛不住;二是达梦DM8虽然支持XA相关接口,但在一些中间件和连接池的组合下,配置链路过长,稍微一复杂就开始出幺蛾子;三是XA对业务代码有侵入,你得在代码里显式管理全局事务的生命周期,分布式系统里一多就乱。
后来我换成了Seata的AT模式,至少业务侧终于能“无侵入”了。
1.2 为什么XA两阶段锁在达梦上不划算
XM的全局锁是把所有参与者的资源全部锁定,直到全局提交或回滚,这个锁的粒度是“数据库级别”的。对达梦这种国产数据库来说,它兼容了Oracle和MySQL两套行为,但XA事务的优先级和资源隔离并不像传统商业数据库那么“顺手”,遇到大事务时,性能衰减非常明显。
更具体一点说,微服务架构里,订单服务和库存服务各连各的库,XA要求两个数据库在同一个全局事务协调器下工作,一旦某个分支事务网络超时,全局事务可能长时间挂着,数据库连接被白白占住。我在项目里试过,并发一上来,DBA那边就开始报警,锁等待、连接池耗尽全来了。这时候Seata AT模式的“最终一致性”思路反而更实用:业务操作正常执行,通过undo_log记录回滚信息,二阶段再异步做补偿,锁的粒度小得多,对数据库的压力也小得多。
1.3 AT模式:用一张undo_log表换来业务无侵入
AT模式的核心机制可以拆成三句话。
一阶段:业务SQL正常执行,但在执行前后,Seata会为每一行受影响的数据生成before image和after image,连同分支事务信息一起写入undo_log表,然后和业务SQL放在同一个本地事务里提交。这个本地事务的提交起着“锁定资源”的作用,相当于用业务数据库自身的行锁代替了全局锁。
二阶段:如果所有分支都成功了,TC通知各分支删除undo_log,这个操作不涉及业务数据,动作很轻。如果任意一个分支失败,TC通知各分支用undo_log里的镜像数据反向生成补偿SQL,把数据改回原来的样子,这个补偿SQL也是在一个本地事务里执行的,同样要落undo_log。
换到达梦的场景,关键在于Seata官方源码里没有达梦的方言实现,你让AT模式的RM端去操作达梦的undo_log,它不知道该生成什么样的SQL、该用哪个元数据缓存类去读表结构、该用哪种方式管理全局锁。所以我们要做的事情,就是把这些“方言”补上。
2. 达梦DM8的三个“个性”,适配前必须心里有数
2.1 用户即模式:连接达梦数据库时的权限与schema坑
达梦DM8跟MySQL最大的区别是,它没有MySQL那种“一个实例多个database”的清晰概念,而是采用了“一个用户就是一个schema(模式)”的设计,这点跟Oracle更像。你创建一个用户,系统会同时创建一个同名的schema,用户的表默认建在自己的schema下。连接的URL是jdbc:dm://127.0.0.1:5236,端口固定5236,登录用户默认是SYSDBA,但生产环境肯定不能全用SYSDBA,要按业务建两个用户,比如订单库的用户叫ORDER_USER,库存库的用户叫INVENTORY_USER。
这个设计对Seata的适配影响很大。Seata的TableMetaCache在读取表结构时,通过JDBC的DatabaseMetaData.getColumns()方法拿列信息,MySQL里第一个参数传database名称,达梦里你需要传schema名称,也就是用户名。拿不到正确的模式名,Seata就识别不了表结构,后面的一切都无从谈起。所以接达梦的第一个动作,就是确认业务表的归属用户,以及Seata客户端连接数据库时使用的用户名,两者必须匹配。
2.2 默认大写和大小写敏感的标识符规则
达梦另一个容易踩的坑是标识符的大小写。默认情况下,不带双引号建的表名和列名,达梦会统一转成大写存储;如果你建表时给表名加了双引号并且用了小写,那后续所有SQL里的表名、列名都必须带双引号保持一致,否则就会报“无效的表名”或者“列不存在”。
这个对从MySQL迁移过来的团队特别不友好。MySQL的SQL语句和表结构习惯用反引号、小写命名,把这些脚本直接拿到达梦上执行,表面上建表成功了,实际上表名在数据字典里是带双引号的小写形式。Seata的元数据解析和SQL生成器默认按照MySQL的规则处理,一查表结构就找不到目标表。
我的建议是:达梦上的业务表、undo_log表,建表时全部统一用大写、不加引号。这样Seata和业务SQL生成器生成的大多数字符串能直接匹配上。
2.3 兼容模式选MySQL还是Oracle,直接影响改造量
达梦在初始化实例的时候有一个COMPATIBLE_MODE参数,决定了数据库对外表现更接近MySQL还是Oracle。这个参数不是随便选的,它直接决定了Seata适配的改造成本。
如果你选MySQL兼容模式,那么达梦会接受AUTO_INCREMENT自增列、LIMIT分页、反引号等MySQL语法。Seata源码里对MySQL的支持是最完善的,MySqlDialect、MySqlTableMetaCache、MySQLUndoLogManager这一套实现可以直接继承复用,只需要针对达梦的特殊点做少量覆盖,比如CLOB字段的读写、系统查询语句的替换。如果你选Oracle兼容模式,Seata的Oracle实现比较依赖Oracle的序列、SYSDATE等特性,达梦虽然兼容了一部分,但细节上仍然有偏差,改造成本明显更高。
所以我强烈建议,如果业务系统是全新开发、没有历史包袱,达梦实例初始化的时候直接选MySQL兼容模式,后面的源码修改会省很多事。如果是老系统从Oracle迁到达梦,那已经是Oracle兼容模式了,也能适配,只是你要做好多改几个方法的心理准备。我们项目因为是从一个偏MySQL的架构迁移过来的,所以走的就是MySQL兼容模式这条路。
3. 环境与版本矩阵:先搭好能跑通的基础设施
3.1 版本搭配:JDK、Spring Boot、Seata、达梦驱动怎么选
我先给一个参考版本矩阵,这些组合是我实测跑通的,如果你用的版本差异太大,后面编译源码、跑demo时可能多出不少额外错误。
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8 | Seata 1.6.x对JDK8的支持最稳,达梦驱动也兼容 |
| Spring Boot | 2.7.x | 2.7是目前企业项目里比较主流的版本 |
| Seata | 1.6.1 | 源码结构清晰,SPI加载机制对二次开发友好 |
| 达梦DM8 | 8.1.x及以上 | 开启MySQL兼容模式 |
| 达梦JDBC驱动 | DmJdbcDriver18.jar | 对应JDK8,驱动类名dm.jdbc.driver.DmDriver |
| Nacos/注册中心 | 2.x | 可选,也可以用Seata内置的file注册方式 |
为什么Seata选1.6.1而不是更新的版本?因为我试过在1.8.x系列上做二次开发,模块拆分变化比较大,而且部分依赖对JDK版本要求更高,适配达梦这种非官方数据库,找一个结构稳定、社区资料多的版本更合适。1.6.1的rm-datasource模块里,方言、undo_log、TableMetaCache这些抽象都做得比较干净,通过EnhancedServiceLoader按dbType加载,非常适合改源码加一个自定义类型。
3.2 达梦初始化:建用户、建业务表、建undo_log表
我用disql或者达梦自带的管理工具执行以下SQL。先在数据库中创建两个业务用户:
CREATE USER ORDER_USER IDENTIFIED BY "order_pass"; CREATE USER INVENTORY_USER IDENTIFIED BY "inventory_pass"; GRANT DBA TO ORDER_USER; GRANT DBA TO INVENTORY_USER;测试环境直接给DBA权限省事,生产环境按最小权限去给,但至少要有建表、增删改查、创建索引的权限。
然后在ORDER_USER下建订单表,注意表名和列名都不带引号、统一大写:
CREATE TABLE ORDER_INFO ( ID BIGINT IDENTITY(1,1) PRIMARY KEY, ORDER_NO VARCHAR(32) NOT NULL, PRODUCT_ID VARCHAR(32) NOT NULL, QUANTITY INT DEFAULT 1, STATUS TINYINT DEFAULT 0 );IDENTITY(1,1)是达梦的自增语法,等价于MySQL的AUTO_INCREMENT。MySQL兼容模式下,写成AUTO_INCREMENT也认,但用IDENTITY更符合达梦原生习惯。
接着在INVENTORY_USER下建库存表:
CREATE TABLE INVENTORY ( ID BIGINT IDENTITY(1,1) PRIMARY KEY, PRODUCT_ID VARCHAR(32) NOT NULL, QUANTITY INT NOT NULL ); INSERT INTO INVENTORY (PRODUCT_ID, QUANTITY) VALUES ('P001', 100);最后是关键的一张表:undo_log。这张表是Seata AT模式的工作基础,每个参与全局事务的业务库都必须建。DDL不能直接照抄MySQL版,我把达梦可用的版本贴出来:
CREATE TABLE UNDO_LOG ( ID BIGINT IDENTITY(1,1) NOT NULL, BRANCH_ID BIGINT NOT NULL, XID VARCHAR(128) NOT NULL, CONTEXT VARCHAR(128) NOT NULL, ROLLBACK_INFO CLOB NOT NULL, LOG_STATUS INT NOT NULL, LOG_CREATED DATETIME(6) NOT NULL, LOG_MODIFIED DATETIME(6) NOT NULL, CONSTRAINT PK_UNDO_LOG PRIMARY KEY (ID) ); CREATE INDEX IDX_UNDO_LOG_XID_BRANCH ON UNDO_LOG (XID, BRANCH_ID);注意ROLLBACK_INFO字段,MySQL官方DDL用的是BLOB,到达梦上我建议用CLOB。原因后面避坑实录里细说,这里先记住结论。
3.3 Seata Server(TC)部署:先别急着改源码
源码修改针对的是客户端RM那一侧,但分布式事务的协调者TC得先跑起来。Seata Server 1.6.1下载解压之后,先修改conf/application.yml,最简配置如下:
server: port: 7091 seata: registry: type: file config: type: file store: mode: fileregistry.type: file表示客户端直接通过IP:端口连TC,不走注册中心,本地验证最方便。store.mode: file表示事务日志存本地文件,也不依赖数据库。以file模式启动:
sh seata-server.sh -p 8091 -h 127.0.0.1-p是TC的服务端口,默认8091;-h是对外暴露的IP。如果是在服务器上部署,-h要填服务器的内网IP,而不是127.0.0.1,否则客户端连不上。测到这一步,整个基础环境就绪了,可以开始动源码。
4. 源码改造四个关键点:让Seata真正认识DM8
4.1 改造点一:DBType枚举与JDBC URL识别
先下载Seata 1.6.1源码,我用的是GitHub上官方仓库的1.6.1tag。第一次改动要加一个数据库类型。
打开common/src/main/java/io/seata/common/model/DBType.java,在枚举里加入DM8:
public enum DBType { MYSQL, ORACLE, ... DM8 }然后打开common/src/main/java/io/seata/common/util/JdbcUtils.java,在getDbType(String jdbcUrl)方法里加入对达梦JDBC URL的识别:
if (jdbcUrl.startsWith("jdbc:dm:")) { return DBType.DM8; }这一步的意义在于,客户端在启动时会根据JDBC URL判断数据源类型,后续所有的方言加载、配置选择都以这个DBType为入口。加了枚举和判断之后,Seata至少不再报“not support dbType”这类错误。
这里有个编译上的连锁反应:DBType加了新枚举,项目里所有对DBType做switch的代码都会提示“未枚举分支”,编译可能要报错。你需要全局搜索switch (dbType)和DBType.MYSQL出现的位置,把DM8补进去,大多数情况下直接复用MYSQL的分支即可。常见的位置包括rm-datasource模块下的SqlGeneratorFactory、UndoLogManagerFactory、TableMetaCacheFactory,以及server模块下的存储工厂类。如果只改了common和rm-datasource,一些依赖common的模块编译时也会影响到,耐心一个个补上就行。
4.2 改造点二:DmDialect方言实现
在Seata 1.6.1里,rm-datasource模块有一个io.seata.rm.datasource.dialect包,里面的MySqlDialect定义了MySQL这套数据库方言对应的各种组件实现。我们的思路是继承它,覆盖需要差异化处理的组件。
新建rm-datasource/src/main/java/io/seata/rm/datasource/dialect/DmDialect.java:
package io.seata.rm.datasource.dialect; import io.seata.common.loader.LoadLevel; import io.seata.rm.datasource.sql.struct.DmTableMetaCache; import io.seata.rm.datasource.undo.DmUndoLogManager; @LoadLevel(name = "dm8") public class DmDialect extends MySqlDialect { @Override public String getDefaultTableMetaCache() { return DmTableMetaCache.class.getName(); } @Override public String getDefaultUndoLogManager() { return DmUndoLogManager.class.getName(); } }@LoadLevel的name就是方言名称,Seata的EnhancedServiceLoader在运行时按dbType加载这个类。SQL生成器、锁管理器这两个组件继续沿用MySQL的实现,因为达梦在MySQL兼容模式下,对这些SQL的兼容性足够。
注意编译的时候,Java文件里引用的DmTableMetaCache和DmUndoLogManager是我们后面要新建的两个类,得先把类建好或者先写一个空壳,再继续往下走。
4.3 改造点三:UndoLogManager的SQL与字段适配
新建rm-datasource/src/main/java/io/seata/rm/datasource/undo/DmUndoLogManager.java,参考同一目录下的MySQLUndoLogManager改。这个类管着undo_log表的增删查,是AT模式二阶段回滚的直接执行者。
核心方法改造如下。
插入undo_log的方法,注意时间函数替换:
@LoadLevel(name = "dm8") public class DmUndoLogManager extends AbstractUndoLogManager { @Override public String getDBType() { return DBType.DM8.name(); } @Override protected void insertUndoLogWithNormal(Connection conn, UndoLogDO undoLog) throws SQLException { String sql = "INSERT INTO UNDO_LOG (BRANCH_ID, XID, CONTEXT, ROLLBACK_INFO, LOG_STATUS, LOG_CREATED, LOG_MODIFIED) " + "VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, undoLog.getBranchId()); ps.setString(2, undoLog.getXid()); ps.setString(3, undoLog.getContext()); ps.setString(4, undoLog.getRollbackInfo()); ps.setInt(5, undoLog.getLogStatus()); ps.executeUpdate(); } } // 查询、删除等方法的实现参考MySQLUndoLogManager,SQL里把now()替换为CURRENT_TIMESTAMP }setString绑到CLOB列上,实测达梦驱动是支持的,不需要额外的类型转换。但查询的时候有坑,MySQL版里读rollback_info用的是rs.getBytes("rollback_info"),这在达梦上大概率拿不到东西或者直接抛异常。因为字段是CLOB类型,要改成这样:
private String getRollbackInfo(ResultSet rs) throws SQLException { java.sql.Clob clob = rs.getClob("ROLLBACK_INFO"); if (clob == null) { return null; } return clob.getSubString(1, (int) clob.length()); }这个改动是源码适配里最容易被忽略的地方。我第一次没改,一阶段插入没问题,二阶段回滚的时候一读CLOB就报“流已关闭”或者拿到null,跟踪日志才发现是字段类型和读取方式不匹配。
另外,deleteUndoLogByLogCreated方法里MySQL版用了LIMIT做历史数据清理,达梦MySQL兼容模式下同样支持LIMIT,所以这条SQL可以保留原样。
4.4 改造点四:TableMetaCache元数据读取
新建rm-datasource/src/main/java/io/seata/rm/datasource/sql/struct/DmTableMetaCache.java。这个类负责读取表结构,Seata执行控制SQL、做镜像对比时都需要它。
参考MySqlTableMetaCache,大部分逻辑可以复用,唯一要改的是getTableMeta里的元数据查询方式。MySQL版用connection.getMetaData().getColumns(catalog, null, tableName, "%"),这个catalog参数在达梦里传database名称或者不传都不会得到预期结果,最稳的方式是通过达梦的系统视图查询。
我这里给一个验证过的思路:查询ALL_TAB_COLUMNS拿列信息,查询ALL_CONSTRAINTS和ALL_CONS_COLUMNS拿主键信息,再把这些结果映射成Seata的TableMeta对象。核心片段示意如下:
String schema = connection.getSchema(); String sql = "SELECT COLUMN_NAME, DATA_TYPE, DATA_LENGTH, NULLABLE " + "FROM ALL_TAB_COLUMNS " + "WHERE OWNER = ? AND TABLE_NAME = ? " + "ORDER BY COLUMN_ID"; String pkSql = "SELECT CC.COLUMN_NAME " + "FROM ALL_CONSTRAINTS C, ALL_CONS_COLUMNS CC " + "WHERE C.OWNER = ? AND C.TABLE_NAME = ? " + "AND C.CONSTRAINT_TYPE = 'P' " + "AND C.CONSTRAINT_NAME = CC.CONSTRAINT_NAME";拿到列名列表和主键列之后,构造TableMeta的字段映射,再设置自增列。注意,达梦的表名、列名默认是大写,返回结果也要按大写匹配,如果你的业务表建表时用了带引号的小写,这里就要额外处理大小写转换。
4.5 编译打包与替换jar包的顺序
改动完成后,不用把整个Seata源码全量重新打包,只需要编译common和rm-datasource两个模块,然后找到对应的jar替换到客户端项目里。
在Seata源码根目录执行:
mvn -pl common,rm-datasource -am clean install -DskipTests构建成功后,到rm-datasource/target/目录下找seata-rm-datasource-1.6.1.jar,到common/target/下找seata-common-1.6.1.jar。你的客户端应用如果用的是seata-all或者seata-spring-boot-starter这类聚合依赖,可能还要同时检查seata-core、seata-tm这些包的编译产物是否变化。
替换jar包有个容易犯的错误:只替换了服务端的Seata,客户端没替换,结果客户端启动时用的还是官方原版代码,新增的DBType.DM8根本没进去。Seata AT模式的RM端运行在业务应用进程里,所以业务应用依赖的所有seata相关jar,都必须是改造后重新编译的版本。最好的做法是先把改好的模块mvn install到本地Maven仓库,然后客户端项目的pom.xml里直接引用本地仓库里的1.6.1版本,版本号可以保持一样,但要确保本地Maven仓库里的jar确实是改造过的。
5. 客户端接入:Spring Boot + Seata Client跑通下单回滚
5.1 依赖与配置:数据源代理、注册中心、事务分组
demo我做了两个服务:order-service和inventory-service。各自连接达梦上不同的用户,这样能真实模拟跨库的分布式事务。
order-service的application.yml核心配置如下:
spring: datasource: url: jdbc:dm://127.0.0.1:5236?schema=ORDER_USER username: ORDER_USER password: order_pass driver-class-name: dm.jdbc.driver.DmDriver seata: application-id: order-service tx-service-group: my_test_tx_group service: vgroup-mapping: my_test_tx_group: default grouplist: default: 127.0.0.1:8091 enable-auto-data-source-proxy: true这里有几个容易搞错的地方。
一是jdbc:dm://127.0.0.1:5236后面可以跟?schema=ORDER_USER来指定默认schema,也可以不指定,由驱动根据用户名推算。但建议显式写上,避免连到SYSDBA的schema下。
二是tx-service-group这个事务分组名,客户端和Seata Server端的配置必须一致。vgroup-mapping: my_test_tx_group: default表示把事务分组映射到TC的集群名default,grouplist则告诉客户端TC的地址。如果后面排查发现客户端一直连不上TC,优先检查这两个配置。
inventory-service的配置类似,数据源换成INVENTORY_USER。两个服务都要引入改造后编译的Seata依赖,确保数据源代理生效。
5.2 demo代码:@GlobalTransactional下的一次完整回滚
order-service的Service层代码,核心就一个注解:
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private InventoryClient inventoryClient; @GlobalTransactional(name = "create-order", rollbackFor = Exception.class) public void createOrder(OrderDTO dto) { Order order = new Order(); order.setOrderNo(dto.getOrderNo()); order.setProductId(dto.getProductId()); order.setQuantity(dto.getQuantity()); orderMapper.insert(order); inventoryClient.deduct(dto.getProductId(), dto.getQuantity()); } }inventory-service的deduct方法里,我故意在写入库存表之后抛出异常,模拟“库存已经扣减但下游处理失败”的场景:
public void deduct(String productId, int quantity) { inventoryMapper.deduct(productId, quantity); // 模拟业务失败 throw new RuntimeException("inventory deduct failed"); }这里的关键在于:@GlobalTransactional开启的是全局事务,inventory-service的本地事务即使自己提交了,如果最终整个全局事务失败,TC也会根据undo_log去回滚。实际跑下来,订单表的INSERT和库存表的UPDATE都会被回滚,库存数量恢复原值。
5.3 验证:日志里的分支提交与回滚痕迹
跑完一次失败用例之后,看两个服务的日志。
正常一阶段日志会类似:
UndoLogManager insert undo log, xid=... branchId=... Branch register success, xid=... branchId=...随后inventory-service抛异常,全局事务进入二阶段回滚,order-service日志里出现:
Branch rollback success, xid=... branchId=...同时到达梦的INVENTORY库查UNDO_LOG表,里面会留下对应的回滚记录。全局事务提交成功的场景下,二阶段会执行delete undo_log,UNDO_LOG里查不到记录;全局事务失败的场景下,undo_log记录会被标记状态并保留一段时间,方便排查。
6. 避坑实录:从报错到跑通的完整排查链路
6.1 UNDO_LOG表DDL:直接抄MySQL版的后果
这个坑几乎每个从MySQL迁到达梦的人都会踩。Seata官方文档里MySQL建表语句的rollback_info字段是BLOB,很多同事直接把这套DDL拿到达梦上执行,表面没问题,但真正跑全局事务的时候,插入undo_log会报错或者数据写入异常。
原因是BLOB在达梦里存二进制数据,而Seata代码里生成的rollback_info本质是JSON字符串,通过JDBC的setString去写入BLOB字段,部分版本的达梦驱动会告诉你“列类型不匹配”。换成CLOB之后,整个链路就顺畅了。同样的问题也会出现在context字段上,不过官方DDL里context本来就是VARCHAR,不需要额外处理。
6.2 大小写不一致导致列找不到
第二个坑和达梦的大小写规则有关。我们的建表脚本一开始是从一个在MySQL上跑得飞起的项目直接复制过来的,表名带了反引号,执行到达梦之后,数据字典里记录的表名是带引号的小写形式。Seata的DmTableMetaCache在查询ALL_TAB_COLUMNS时传入ORDER_INFO,结果查不到任何列,日志里报“Failed to get table meta”。这个问题的排查链路比较长:先从TableMetaCache下手,打印出它实际查询的schema和tableName,再去数据库里查ALL_TAB_OBJECTS对比大小写,最后才发现是建表时的引号问题。
解决方案就是前面说的:建表时全部用不带引号的大写标识符,然后Seata侧查询时也统一传大写。如果你有很多历史表已经是带引号的小写,那就得在DmTableMetaCache里加一个大小写转换逻辑,把传入的表名统一按照数据字典里的实际值做一次匹配,这个改动比较繁琐,不如直接规范建表省事。
6.3 全局锁等待超时:undo_log的记录没被删除
AT模式的全局锁,实际上是用数据库自身的行锁去实现。一阶段本地事务提交前,Seata会对受影响的行执行SELECT ... FOR UPDATE,拿到这个行的锁之后,业务SQL才放行。如果某个分支事务执行成功,但TC迟迟没有收到二阶段提交指令,或者分支事务的处理线程挂了,那这些行锁就会一直占着,后续所有操作同一行的全局事务都会卡在全局锁等待上,日志里会出现“Global lock wait timeout”之类的关键字。
排查这种问题,先看是不是某个分支事务的本地事务没有正常提交。一个常见的原因是连接池配置里开启了自动提交,导致业务SQL和undo_log插入不在同一个本地事务里,一阶段提交时序乱了,全局锁持有时间变长。
另外,如果全局事务失败后,undo_log记录没有被清理,也会出现越积越多的情况。正常情况下回滚完成后undo_log记录会保留一条状态为“已回滚”的数据,如果连这条记录都没有,说明二阶段的rollback SQL根本没执行成功。这时候要重点看回滚日志里是不是有针对CLOB读取的异常,大概率就是4.3节说的rs.getBytes那个坑。
6.4 自增主键关闭:ID字段必须显式IDENTITY
还有一个比较隐蔽的问题:达梦对自增列的管理和MySQL不完全一样。MySQL里只要字段定义了AUTO_INCREMENT,插入时不传该字段就能自动生成;达梦的MySQL兼容模式下,AUTO_INCREMENT大部分时候也可以用,但有些版本或者某些特殊配置下,如果不给ID字段传值,插入会报“不能为NULL”或者“违反非空约束”。
我在测试时遇到过这样的情况:业务表建表时用了INT PRIMARY KEY,没加IDENTITY,Seata生成INSERT语句时没有包含ID字段,达梦直接报错。后来把ID列的DDL改成BIGINT IDENTITY(1,1) PRIMARY KEY,问题就消失了。如果你在建表时确实不想用自增,那业务代码里必须手动给ID赋值,否则Seata的insert语句不会帮你生成主键。
6.5 关于TC端存储的国产化补充建议
最后说一下TC端存储。前面的部署用的是file存储,单机验证没问题,但生产环境一般会改成DB存储或者Redis存储。DB存储的好处是TC重启后事务记录不丢,支持集群。如果项目要求全链路不出现其他商业数据库,想把TC的事务日志也放到达梦上,这个思路可行,但改造成本会明显上升。
TC端DB存储用的是server模块里的DAO层,里面有大量SQL是针对MySQL、Oracle这些数据库写的,达梦兼容MySQL模式下,可以尝试把store.db.db-type配置成mysql去走MySQL那套SQL,部分版本可能跑得通,但稳定性需要压测。更稳妥的方案是TC用Redis存储事务日志,Redis是国产化项目里允许使用的中间件,这样避免了再次改源码,又能满足高可用。我当时为了控制风险,最终生产环境就是用Redis存储,业务库全部用达梦,整体链路跑了一年多没有出过问题。
从一个坑一个坑踩过来回头看,Seata AT模式适配达梦DM8,本质上不是多复杂的深水区,核心就四件事:让JDBC URL识别达梦、让方言加载器认识dm8、让undo_log的读写符合达梦字段特性、让元数据读取查询达梦的系统表。把这四件事做完,分布式事务在达梦上就能跟MySQL一样顺畅运转。如果你也正在做类似的事情,按着这篇文章的顺序来,能少走不少弯路。