
如果你负责的后台系统里也有一块“配置导入”这类功能那你大概率遇到过这种场景导入一批配置接口转了大半天最后超时回头查数据库一部分数据更新了一部分没更新再翻日志全是Lock wait timeout exceeded。我之前负责的一个内部后台系统就是因为这个“配置导入事务问题”连续排查了两天。最后把问题拆开来看其实根子就两个字事务用错了。这篇文章想把当时从故障现场、根因定位、修复落地到后续沉淀的完整过程记录下来。内容不限定在某个具体业务而是把“配置导入”这个场景下的事务设计思路、代码实现和避坑经验都整理一遍给正在做配置中心、后台管理、批量导入导出功能的朋友一个可直接参考的方案。1. 问题现场配置导入为什么会超时、死锁、数据不一致1.1 业务背景与最初的实现方式先交代一下当时系统的形态。这是一个内部运营后台管理员需要定期把一批配置项通过 Excel 文件批量导入系统。每个配置项大概长这样应用名、环境、配置键、配置值、配置描述、是否支持热更新等。功能本身不复杂初版实现也“很直接”文件上传 → 服务端解析 Excel → 循环调用保存方法逐条写入数据库 → 全部成功后返回结果。为了保证“要么全部成功要么全部失败”最自然的想法就是把整个导入方法包在一个大事务里。当时的代码大致是这样的Transactional(rollbackFor Exception.class) public ImportResult importConfigs(MultipartFile file) { ListConfigItem 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 三段式导入流程设计修复的核心思路是不要让一个事务包住所有事情而是把导入过程拆成若干个阶段每个阶段用不同的事务策略。我最终落地的方案是一个“三段式”流程解析与校验阶段读取文件解析成结构化的配置对象做格式校验、重复键校验、业务规则校验。这个阶段不开启事务或者最多开启一个只读事务。预检与变更计算阶段将解析结果与数据库现有数据做对比生成“新增”“更新”“无变化”三类变更集并写入一批次记录。分批执行导入阶段把变更集按照固定大小切分成多个批次每个批次单独开启一个短事务。批次之间相互独立某一批失败了不影响已经成功的批次。这个设计的直接好处是单事务的持续时间从“整个接口耗时”降为“一批评数百条数据的写入耗时”连接池压力、锁竞争和主从延迟都大幅下降。3.2 分批事务的实现从 Transactional 到 TransactionTemplate在主方法上继续使用Transactional已经不适合了因为我们需要事务粒度精确控制到“每一批”而不是“整个导入过程”。这里我改用 Spring 的TransactionTemplate做编程式事务控制。伪代码大致如下public ImportResult importConfigs(MultipartFile file) { // 第一阶段解析与校验无事务 ListConfigItem items parseAndValidate(file); // 第二阶段预检与变更计算无事务或只读事务 ListConfigItem toUpdate diffWithDatabase(items); // 创建导入批次记录 Long batchId createImportBatch(items.size()); // 第三阶段分批导入每批一个短事务 int batchSize 500; int successCount 0; ListBatchError errors new ArrayList(); for (ListConfigItem 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的作用是把总数据切成若干个小批。批大小一般选择 200500 条比较合适太大会重新变成大事务太小则提交次数过多性能反而下降。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_trxSELECT 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 配置导入场景的额外经验补充几个这次改动之后沉淀下来的、针对“配置导入”这个具体场景的经验文件解析一定放事务外。解析 Excel、校验字段、格式化值是纯 CPU 和 IO 操作放进事务只会白白占用连接。即便是“只读事务”也让数据库多承担无谓的开销。循环单条 save 改成批量写入。MyBatis 的saveBatch或者 JDBC 的rewriteBatchedStatements都能显著减少数据库往返次数。同样是 5000 条数据逐条写和分批写性能差距在数倍以上。导入过程中禁止人工修改数据。如果导入配置的同时有其他管理员在后台手动改了某个配置的取值很可能导致版本号错乱、覆盖方向不确定。给导入功能加一个“配置维护锁”导入期间禁止编辑同应用同环境下的配置或者用乐观锁版本号做保护防止互相覆盖。注意文件内配置的依赖关系。有些配置之间是有关联的比如服务A的地址配置服务于服务B的调用配置。如果导入一批配置时只按文件顺序写入可能会先写入被依赖的配置再写入依赖方导致下游在事务提交前拿到中间状态。安全的做法是导入前先整体计算配置的依赖关系把有依赖的配置放在同一个批次里写入或者干脆全部校验通过后再统一发布通知。唯一键冲突不要靠异常兜底。导入前先用数据库查询验证“应用名环境配置键”是否存在而不是靠INSERT报错后捕获唯一键异常。因为唯一键异常会导致当前事务回滚如果你还在事务里的话还会影响批次的控制流程。5. 修复后的效果与一些个人体会这次重构上线之后效果比较明显。用同一份 5000 条配置的文件测试导入时间从 40 秒以上降到了 5 秒左右数据库连接池不再被打满锁等待超时的告警基本消失。更大的收益是“结果可解释”导入完成之后用户能看到成功了多少、失败了多少、每一条失败的原因是什么甚至可以针对失败条目单独重试不再需要登录数据库手工修数据。我个人在这轮排查和修复里最大的体会是配置导入这类功能第一版用一个大事务包起来确实是最省事的写法但“省事”只是对开发省事对系统运行和后续维护都不省事。事务边界怎么划本质上取决于业务对一致性、可用性和可排查性的取舍而不是“能不能用 Transactional 一把梭”。另外想提醒做同类功能的朋友不是只有金额变动才需要认真设计事务。配置类数据虽然不像交易数据那么敏感但它影响面往往更大一次错误导入可能让整个线上系统拿到错误配置而且不像订单那样容易被对账发现。所以无论是批次号、版本号、变更快照还是事后补偿这些看似“过度设计”的东西在关键配置场景下都值得做。等真的出了事故再去补成本远高于一开始就架构进去。如果后续要在这个方案上继续扩展可以考虑把“导入”抽象为一个异步任务前端只提交导入请求后台通过线程池或消息队列执行导入流程导入结果通过站内信或回调通知用户。这样连接口超时都可以彻底规避掉。但那是另一个话题了这次的配置导入事务问题到这一步已经算收尾了。