☰
配置导入事务问题:大事务引发超时死锁,拆分批处理才是解药
2026/10/10 17:01:59 网站建设 项目流程

如果你负责的后台系统里也有一块“配置导入”这类功能,那你大概率遇到过这种场景:导入一批配置,接口转了大半天,最后超时;回头查数据库,一部分数据更新了,一部分没更新;再翻日志,全是Lock wait timeout exceeded。我之前负责的一个内部后台系统,就是因为这个“配置导入事务问题”连续排查了两天。最后把问题拆开来看,其实根子就两个字:事务用错了。

这篇文章想把当时从故障现场、根因定位、修复落地到后续沉淀的完整过程记录下来。内容不限定在某个具体业务,而是把“配置导入”这个场景下的事务设计思路、代码实现和避坑经验都整理一遍,给正在做配置中心、后台管理、批量导入导出功能的朋友一个可直接参考的方案。

1. 问题现场:配置导入为什么会超时、死锁、数据不一致

1.1 业务背景与最初的实现方式

先交代一下当时系统的形态。这是一个内部运营后台,管理员需要定期把一批配置项通过 Excel 文件批量导入系统。每个配置项大概长这样:应用名、环境、配置键、配置值、配置描述、是否支持热更新等。功能本身不复杂,初版实现也“很直接”:

文件上传 → 服务端解析 Excel → 循环调用保存方法逐条写入数据库 → 全部成功后返回结果。

为了保证“要么全部成功,要么全部失败”,最自然的想法就是把整个导入方法包在一个大事务里。当时的代码大致是这样的:

@Transactional(rollbackFor = Exception.class) public ImportResult importConfigs(MultipartFile file) { List<ConfigItem> items = parseFile(file); // 1. 解析文件,比较耗时 for (ConfigItem item : items) { configMapper.saveOrUpdate(item); // 2. 逐条写入数据库 notifyDownstream(item); // 3. 通知下游服务刷新配置 } return ImportResult.success(items.size()); }

这个写法在数据量小的时候没什么问题,几十条、几百条配置,事务很快结束。但一旦数据量上来,问题就开始暴露了。

1.2 线上暴露的三类问题

当时不是直接崩掉,而是一个一个信号冒出来的。我按时间线梳理一下:

第一波是接口超时。运维在后台导入一份包含 5000 条配置的文件,前端请求一直转圈,最后网关报超时。看日志,整个导入方法执行了 40 多秒,数据库连接被这个长事务占住不放。

第二波是连接池告警。由于接口超时,前端可能重试,后台还可能有其他业务在竞争连接。这个长事务把连接池里的连接耗光了,其他请求全部排队,系统表现为“卡死”。

第三波是数据不一致。某次导入中间报错,事务回滚了,数据库里的配置没有变,但 Redis 缓存里已经有一半是新配置,下游服务也刷新到了部分新值。配置数据对不上,排查了很久才怀疑到“事务内发消息”这个环节上。

顺带还会出现间歇性的死锁报错。原因是导入了大量相同的配置键,多个请求或批次之间互相争抢同一批行锁,一旦锁等待超时,整个事务就直接回滚。

1.3 现象背后的核心矛盾

这三类问题表面上看是“超时”“死锁”“数据不一致”,但归根结底是三个矛盾叠加在一起:

  • 事务边界太大,导致持锁时间等于整个接口耗时。
  • 事务内部做了远程调用,导致事务迟迟不结束,外部系统还看到了“半成品”数据。
  • 异常处理不当,导致 Spring 声明式事务在某些路径下没有回滚。

所以这不是单一配置问题,而是“事务设计”的典型反面教材。搞清楚这几个矛盾之后,修复方案也就有了方向。

2. 根因分析:这个事务到底错在哪里

2.1 事务边界过大:持锁时间约等于接口耗时

在数据库层面,事务开启后会持有行锁、间隙锁等资源,直到事务提交或回滚才释放。一个大事务包住整个导入流程,意味着锁的持有时间不取决于“写一条数据需要多久”,而取决于“整个接口需要多久”。

拿当时的场景举例:5000 条数据逐条saveOrUpdate,假设每条平均耗 5ms,光数据库写入就要 25 秒。如果把文件解析、网络通知、日志记录都放在事务里,时间会更长。

这带来的最直接影响是锁竞争。别的业务也想操作同一张表或同一批记录,但锁被这个长事务攥着,只能干等。等的时间一旦超过数据库innodb_lock_wait_timeout的默认值(通常是 50 秒),就会直接报错回滚。更麻烦的是,长事务还可能导致主从复制的延迟变大,因为从库要连续执行大量 binlog 事务,主库提交了,从库还在一大段一大段地追。

注意:事务不是“越早开启越好”,而是“越晚开启越好,越早提交越好”。只读操作、解析操作、网络操作都不应该放在事务里。

2.2 事务内远程调用:下游看到了还没提交的数据

第二个问题更隐蔽。事务内调用notifyDownstream(item)的意思是:每写一条配置,就立即给下游服务发一个消息或调用一次刷新接口。但此时数据库事务还没提交,下游去查库,查到的可能是旧值,甚至根本查不到这条记录(如果是 insert 场景)。

我当时遇到的情况就是这样:配置值从 A 改成 B,第 10 条写入成功后,下游立刻刷新缓存,读到的还是 A。因为数据库层面的 update 虽然是可见的(未提交事务的修改对自身可见),但其他服务在默认隔离级别下读不到未提交的数据,所以下游拿到的是旧值。等到整个事务全部提交,已经没有新的通知再发给它了,于是缓存和数据就永久不一致。如果有后续请求继续依赖这个缓存,影响范围会更大。

正确做法是把“通知下游”这件事情放到事务提交之后再执行。Spring 的事务同步机制可以做到这一点,后面细说。

2.3 异常被吞掉:Spring 事务静默失效

另一个非常经典的问题:代码里手写了 try-catch,把异常捕获并打印日志,然后继续执行或直接返回错误。Spring 的声明式事务是基于 AOP 拦截异常来触发回滚的,如果异常在业务代码里就被吞掉,事务管理器根本感知不到,事务就会正常提交。

当时某段导出逻辑里写的是这样:

try { configMapper.saveOrUpdate(item); } catch (Exception e) { log.error("保存配置失败", e); // 这里没有抛出异常,事务不会回滚 }

结果就是,一批 5000 条数据,中间有 3 条因为字段超长保存失败,但日志只记录错误,事务继续执行到最后正常提交。那 3 条数据没写进去,其他 4997 条都写进去了。用户看到的提示却是“导入成功”。这种“部分成功但提示成功”的假象,在配置类数据场景里非常危险,因为它意味着线上配置处于不确定状态。

2.4 缺少幂等与批次追踪

还有一个隐患:整个导入没有“批次”概念。也就是说,每条配置没有记录是“哪一次导入”创建的,也没有版本号。一旦导入结果不可信,想回滚或者重试,根本不知道哪些数据是这次导入带进去的,哪些是历史数据。

更尴尬的是,即使想对配置文件里的某一条做“只更新成功的那部分”也不行。因为下一次导入又是全量覆盖式写入,新的脏数据会把之前的数据再覆盖一遍。没有批次号、没有版本号、没有变更记录,所有修复手段都无从下手。

这也是后续重构中很重要的一环:给配置导入加上“批次”和“版本”的概念,让导入可追踪、可回滚、可重试。

3. 修复方案:拆事务、去远程调用、加幂等与补偿

3.1 三段式导入流程设计

修复的核心思路是:不要让一个事务包住所有事情,而是把导入过程拆成若干个阶段,每个阶段用不同的事务策略。

我最终落地的方案是一个“三段式”流程:

  1. 解析与校验阶段:读取文件,解析成结构化的配置对象,做格式校验、重复键校验、业务规则校验。这个阶段不开启事务,或者最多开启一个只读事务。
  2. 预检与变更计算阶段:将解析结果与数据库现有数据做对比,生成“新增”“更新”“无变化”三类变更集,并写入一批次记录。
  3. 分批执行导入阶段:把变更集按照固定大小切分成多个批次,每个批次单独开启一个短事务。批次之间相互独立,某一批失败了不影响已经成功的批次。

这个设计的直接好处是:单事务的持续时间从“整个接口耗时”降为“一批评数百条数据的写入耗时”,连接池压力、锁竞争和主从延迟都大幅下降。

3.2 分批事务的实现:从 @Transactional 到 TransactionTemplate

在主方法上继续使用@Transactional已经不适合了,因为我们需要事务粒度精确控制到“每一批”,而不是“整个导入过程”。这里我改用 Spring 的TransactionTemplate做编程式事务控制。

伪代码大致如下:

public ImportResult importConfigs(MultipartFile file) { // 第一阶段:解析与校验(无事务) List<ConfigItem> items = parseAndValidate(file); // 第二阶段:预检与变更计算(无事务或只读事务) List<ConfigItem> toUpdate = diffWithDatabase(items); // 创建导入批次记录 Long batchId = createImportBatch(items.size()); // 第三阶段:分批导入,每批一个短事务 int batchSize = 500; int successCount = 0; List<BatchError> errors = new ArrayList<>(); for (List<ConfigItem> batch : partition(toUpdate, batchSize)) { try { transactionTemplate.executeWithoutResult(status -> { for (ConfigItem item : batch) { configMapper.saveOrUpdate(item); configMapper.updateBatchInfo(batchId, item.getId()); } }); successCount += batch.size(); } catch (Exception e) { errors.add(new BatchError(batch, e.getMessage())); log.error("批次导入失败", e); } } // 记录批次结果、失败原因 finishImportBatch(batchId, successCount, errors.size()); return ImportResult.partialSuccess(successCount, errors); }

几个关键点说明一下:

  • partition的作用是把总数据切成若干个小批。批大小一般选择 200~500 条比较合适,太大会重新变成大事务,太小则提交次数过多,性能反而下降。
  • executeWithoutResult里如果某个批次抛出异常,事务会回滚,但外层捕获了异常,所以不影响后续批次继续执行。
  • 每个批次结束后,数据库连接会释放、锁会释放、binlog 会被及时刷新,整体风险降了一个量级。
  • 使用编程式事务之后,主方法上的@Transactional需要去掉,否则事务边界又会扩大。

这里要明确一个点:拆分成多个小事务后,我们放弃了“全有或全无”的强一致性,换来的是可用性和可控性。如果业务方严格要求“要么全部导入,要么一条都不导入”,那不能硬拆,而是应该做好校验和备份,或者引入后续补偿机制。大多数配置导入场景,可接受的是“能告诉用户哪些成功、哪些失败,并且失败可以修复后重试”。

3.3 把“通知下游”移到事务提交后

去掉了事务内远程调用之后,需要在事务提交后再通知下游。Spring 提供了比较优雅的方式:@TransactionalEventListener。

原来的notifyDownstream(item)是每保存一条就调用一次,现在可以改成在导入完成后发出一个事件,等事务提交了再统一通知。示例:

@Service public class ConfigImportService { private final ApplicationEventPublisher eventPublisher; public void importConfigs(...) { // ...导入逻辑... // 收集本次导入的所有变更项 eventPublisher.publishEvent(new ConfigImportedEvent(importedItems)); } }

监听端使用:

@Component public class ConfigImportedListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onConfigImported(ConfigImportedEvent event) { for (ConfigItem item : event.getItems()) { downstreamClient.refresh(item); } } }

@TransactionalEventListener的默认行为是:如果没有事务在运行,事件不会被处理(可以配置fallbackExecution = true来改变)。这里正好符合我们的需求:导入方法里没有大事务了,但每个批次是通过TransactionTemplate进入事务的,最后发布事件时并不在事务里,所以可能需要调整一下:把事件发布放到最后一个批次的事务提交之后。

更通用的做法是:不依赖@TransactionalEventListener的事件时机,而是在整个导入流程结束后(可能是异步任务),显式地发布一个“导入完成”的消息,由消费者统一刷新。如果中途有批次失败,就只通知成功的变更项,失败的等重试成功后再通知。

这里没有唯一正确答案,取决于业务对一致性的要求。但在任何情况下,都不要在事务内直接调用下游接口,这是底线。

3.4 批次表与版本号:让失败可追踪、可恢复

为了支持“哪些配置是这次导入的”“如果失败了如何回滚”,我额外加了两张表和两个字段。

批量导入批次表:

CREATE TABLE config_import_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_no VARCHAR(32) NOT NULL, status TINYINT NOT NULL COMMENT '0-待处理 1-导入中 2-成功 3-部分失败 4-失败', total_count INT NOT NULL, success_count INT NOT NULL DEFAULT 0, fail_count INT NOT NULL DEFAULT 0, error_detail TEXT NULL, operator VARCHAR(64) NOT NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_batch_no (batch_no) );

配置表增加两个字段:

ALTER TABLE config_item ADD COLUMN batch_no VARCHAR(32) NULL, ADD COLUMN version INT NOT NULL DEFAULT 1, ADD INDEX idx_batch_no (batch_no);

写入配置时,把当前导入的批次号写上去,下次导入如果配置内容有变化,version就加一。这样一来,任何一个配置项都能追溯到“最后一次是被哪个批次修改的”。

再加上变更前快照表(可选):

CREATE TABLE config_item_snapshot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, config_id BIGINT NOT NULL, old_value TEXT NOT NULL, new_value TEXT NOT NULL, batch_no VARCHAR(32) NOT NULL, create_time DATETIME NOT NULL );

当配置导入后发现数据有问题,可以按批次号从快照表里把旧值捞出来,反向执行一次update,完成“回滚”。这样即使分批导入后出现部分成功,也能精准恢复。

3.5 失败后的补偿机制:回滚还是重试

失败之后怎么处理,取决于失败原因。

如果失败原因是“校验不通过”或“数据格式错误”,这类属于确定性错误,重试也会失败,应该直接标记该批次为失败,并把错误原因记录到error_detail里,提示用户修改后重新导入。不需要自动重试。

如果失败原因是“数据库连接抖动”“唯一键冲突”“临时锁等待”这类瞬时错误,可以做有限次重试。比如每个批次失败后延迟 1 秒重试一次,最多重试 2 次。

如果业务上要求“导入失败后必须恢复原状”,可以走快照回滚。回滚的意思不是“把数据库里的配置删掉”,而是把快照里的old_value写回去。这里要注意:回滚时也要记录回滚批次,且要以配置文件里的“唯一键”(应用名 + 环境 + 配置键)为维度进行匹配,不能以数据库主键为维度,否则下次导入新增的记录会残留。

从成本角度看,配置类数据一般不涉及资金账户,业务上更看重的是“能定位、能重放、能恢复”,而不是每一条都做分布式事务。所以我的建议是:优先做“可追踪 + 可重试”,快照回滚作为兜底手段,而不是默认执行。

4. 复盘:事务使用中的那些坑与排查技巧

4.1 事务失效的典型场景速查

修复过程中我们遇到了很多“事务失效”的坑,整理成一张表,方便排查时对照:

场景现象原因解决方法
同类内部方法调用事务不生效自调用绕过了 Spring AOP 代理注入自身代理,或将事务方法放到另一个 Bean
异常被 try-catch 捕获数据部分写入,事务不回滚事务管理器感知不到异常catch 块里显式抛出 RuntimeException,或使用编程式事务
方法不是 public事务不生效Spring AOP 默认只拦截 public 方法改为 public,或换用 AspectJ 模式
数据库表不支持事务事务表现“好像失效”表引擎是 MyISAM改为 InnoDB
多数据源配置事务在某些库上不生效事务管理器选错了数据源按数据源分别配置 TransactionManager

这几种问题里,最坑的是“自调用”和“异常被吞”,因为代码看起来是对的,日志也不报错,但行为完全不符合预期。排查时可以打开 Spring 的@EnableTransactionManagement日志,或者直接打印事务代理对象看看是不是代理类。

4.2 如何快速定位大事务

修复之后,如果再遇到“数据库连接池被打满”“锁等待超时”这类问题,怎么快速确认是不是大事务导致的?

最直接的办法是查information_schema.innodb_trx:

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_runtime_seconds FROM information_schema.innodb_trx ORDER BY trx_started ASC;

重点看trx_runtime_seconds特别大、trx_state一直是RUNNING的记录。这些就是长事务,再配合SHOW PROCESSLIST找到对应连接,基本就能定位到是哪个接口开启的事务。

另外,在应用侧可以通过监控工具看方法调用耗时。如果一个 Service 方法整体耗时很高,并且方法开头和结尾没有发 MQ、没有调用外部接口,就大概率是事务内写操作太多或锁竞争严重。此时优先怀疑“事务边界是否过大”,而不是去调数据库参数。

4.3 配置导入场景的额外经验

补充几个这次改动之后沉淀下来的、针对“配置导入”这个具体场景的经验:

  1. 文件解析一定放事务外。解析 Excel、校验字段、格式化值,是纯 CPU 和 IO 操作,放进事务只会白白占用连接。即便是“只读事务”,也让数据库多承担无谓的开销。

  2. 循环单条 save 改成批量写入。MyBatis 的saveBatch或者 JDBC 的rewriteBatchedStatements都能显著减少数据库往返次数。同样是 5000 条数据,逐条写和分批写,性能差距在数倍以上。

  3. 导入过程中禁止人工修改数据。如果导入配置的同时,有其他管理员在后台手动改了某个配置的取值,很可能导致版本号错乱、覆盖方向不确定。给导入功能加一个“配置维护锁”,导入期间禁止编辑同应用同环境下的配置,或者用乐观锁版本号做保护,防止互相覆盖。

  4. 注意文件内配置的依赖关系。有些配置之间是有关联的,比如服务A的地址配置服务于服务B的调用配置。如果导入一批配置时只按文件顺序写入,可能会先写入被依赖的配置,再写入依赖方,导致下游在事务提交前拿到中间状态。安全的做法是:导入前先整体计算配置的依赖关系,把有依赖的配置放在同一个批次里写入,或者干脆全部校验通过后再统一发布通知。

  5. 唯一键冲突不要靠异常兜底。导入前先用数据库查询验证“应用名+环境+配置键”是否存在,而不是靠INSERT报错后捕获唯一键异常。因为唯一键异常会导致当前事务回滚(如果你还在事务里的话),还会影响批次的控制流程。

5. 修复后的效果与一些个人体会

这次重构上线之后,效果比较明显。用同一份 5000 条配置的文件测试,导入时间从 40 秒以上降到了 5 秒左右,数据库连接池不再被打满,锁等待超时的告警基本消失。更大的收益是“结果可解释”:导入完成之后,用户能看到成功了多少、失败了多少、每一条失败的原因是什么,甚至可以针对失败条目单独重试,不再需要登录数据库手工修数据。

我个人在这轮排查和修复里最大的体会是:配置导入这类功能,第一版用一个大事务包起来确实是最省事的写法,但“省事”只是对开发省事,对系统运行和后续维护都不省事。事务边界怎么划,本质上取决于业务对一致性、可用性和可排查性的取舍,而不是“能不能用 @Transactional 一把梭”。

另外想提醒做同类功能的朋友:不是只有金额变动才需要认真设计事务。配置类数据虽然不像交易数据那么敏感,但它影响面往往更大,一次错误导入可能让整个线上系统拿到错误配置,而且不像订单那样容易被对账发现。所以无论是批次号、版本号、变更快照还是事后补偿,这些看似“过度设计”的东西,在关键配置场景下都值得做。等真的出了事故再去补,成本远高于一开始就架构进去。

如果后续要在这个方案上继续扩展,可以考虑把“导入”抽象为一个异步任务:前端只提交导入请求,后台通过线程池或消息队列执行导入流程,导入结果通过站内信或回调通知用户。这样连接口超时都可以彻底规避掉。但那是另一个话题了,这次的配置导入事务问题,到这一步已经算收尾了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询