漫谈Agent系统中的长事务处理:从踩坑到方案演进

最近在优化一个AI Agent系统的文档处理流程时,踩了个坑——一个看起来合理的设计,在实际运行时差点把数据库连接池搞爆了。排查后发现是长事务惹的祸,这也让我重新思考了Agent系统中事务管理的最佳实践。

今天就把这次踩坑经历和处理方案整理出来,希望能帮到同样遇到这类问题的同学。

问题的根源:一个看似合理的"大事务"

事情是这样开始的

我们的文档处理流程是这样的:

用户上传文档 → 解析文档 → 数据处理 → 创建任务 → 后续处理

一开始的设计很直觉:为了保证数据一致性,把所有数据库操作放在一个大事务里。看起来没问题对吧?

@Transactional
public void handleDocumentCreated(DocumentCreatedEvent event) {
    // 解析文档(耗时操作,放在事务外)
    Document doc = parserService.parse(filePath);

    // 然后在一个大事务里完成所有写入操作
    transactionTemplate.execute(status -> {
        // 步骤1:构建数据结构
        structureService.buildStructure(docId, doc.getData());

        // 步骤2:批量创建数据项(可能上千条)
        dataService.batchCreateItems(docId, doc.getItems());

        // 步骤3:创建子任务(可能上千个)
        for (Item item : doc.getItems()) {
            taskService.createTask(docId, item.getId(), ...);
        }

        // 步骤4:更新统计信息
        statsService.updateCount(docId, doc.getItems().size());
    });
}

踩坑时刻:大文档的处理

系统上线后运行正常,直到处理了一个大文档…

现象:

  • 处理时间很长(数分钟)
  • 数据库连接池报警:活跃连接数接近上限
  • 其他用户的请求开始排队等待
  • 系统响应变慢,部分请求超时

排查过程:

打开数据库监控,发现那个大文档的处理任务持有了数据库连接很长时间。在这期间:

  1. 事务锁定了相关的表(防止并发修改)
  2. 其他用户的读写请求被阻塞
  3. 连接池里的其他连接也逐渐被占用(新请求进来)

更要命的是:如果在中途某个步骤失败了(比如网络抖动、超时),前面已完成的工作全部回滚。下次重试又要从头开始,又是一个长事务…

问题的本质

这个坑的本质是:把"数据一致性"和"性能"对立起来了

我们以为"大事务保证一致性",但实际上:

  • 文件解析这种耗时操作已经放在事务外了(这部分做得对)
  • 但剩下的批量写入操作(可能几千条记录)仍然在一个事务里
  • 批量插入本身很快,但加上事务管理和锁等待,时间就长了
  • 事务越长,锁持有时间越长,阻塞其他请求的概率越高

所以真正的问题是:事务范围太大,包含了太多写入操作

一个容易被忽略的问题:AI调用在事务中

在我踩坑的过程中,还发现了一个很容易被忽略的问题:把AI调用放在事务里

为什么这是个隐蔽的坑?

很多开发者的直觉是:“为了保证数据一致性,整个流程应该在一个事务里完成”。于是写出了这样的代码:

@Transactional
public void processDocument(Document doc) {
    // 步骤1:解析文档(快)
    DocumentData data = parserService.parse(doc);

    // 步骤2:保存数据到数据库(快)
    dataService.save(data);

    // 步骤3:调用AI模型提取摘要(慢!)
    String summary = aiService.extractSummary(data);
    data.setSummary(summary);

    // 步骤4:调用AI模型提取关键词(慢!)
    List<String> keywords = aiService.extractKeywords(data);
    data.setKeywords(keywords);

    // 步骤5:更新数据库(快)
    dataService.update(data);
}

看起来很合理对吧?但实际上这是个严重的坑。

AI调用的耗时有多长?

AI调用通常比数据库操作慢得多:

操作类型 典型耗时 说明
数据库插入 10-100毫秒 本地操作,很快
数据库查询 1-50毫秒 本地操作,很快
AI模型调用(小模型) 1-5秒 需要网络通信+推理
AI模型调用(大模型) 5-30秒 大模型推理更慢
AI模型调用(超时) 30-60秒 可能超时或失败

问题: 在一个事务里调用AI模型,意味着数据库连接被占用5-30秒(甚至更长)。

实际踩坑场景

我之前遇到过一个更隐蔽的场景:

@Transactional
public void processWithRetry(Document doc) {
    // 步骤1:保存文档(快)
    docService.save(doc);

    // 步骤2:调用AI模型提取信息(慢)
    try {
        AIResult result = aiService.extract(doc);
        doc.setAiResult(result);
    } catch (Exception e) {
        // AI调用失败,重试
        AIResult result = aiService.retryExtract(doc);  // 又一次慢调用
        doc.setAiResult(result);
    }

    // 步骤3:更新文档(快)
    docService.update(doc);
}

更糟糕的是:

  • 第一次AI调用耗时15秒
  • 失败后重试又耗时20秒
  • 整个事务持有连接35秒!

在这35秒里:

  • 其他用户的请求被阻塞
  • 数据库连接池被占用
  • 系统响应变慢

为什么容易被忽略?

我认为有几个原因:

  1. 直觉上的误解:以为"AI调用很快",但实际上AI模型调用通常需要几秒甚至几十秒

  2. 测试环境的掩盖:在本地测试时,AI调用可能用Mock数据(很快),没有暴露问题

  3. 流量小的掩盖:在低流量环境下,偶尔的长事务不会明显影响其他用户

  4. 超时机制的掩盖:如果AI调用设置了超时(如60秒),在超时前可能没意识到问题

  5. 数据量小的掩盖:处理小文档时,整体耗时不长,问题不明显

正确的做法:AI调用放在事务外

原则: 所有网络调用(AI、外部API、RPC)都应该放在事务外。

public void processDocument(Document doc) {
    // 步骤1:解析文档(事务外,快)
    DocumentData data = parserService.parse(doc);

    // 步骤2:调用AI模型提取摘要(事务外,慢)
    String summary = aiService.extractSummary(data);
    data.setSummary(summary);

    // 步骤3:调用AI模型提取关键词(事务外,慢)
    List<String> keywords = aiService.extractKeywords(data);
    data.setKeywords(keywords);

    // 步骤4:保存和更新数据(事务内,快)
    transactionTemplate.executeWithoutResult(status -> {
        dataService.save(data);
        dataService.update(data);
    });
}

关键点:

  • AI调用耗时操作放在事务外
  • 只有数据库操作放在事务内
  • 事务持有时间从30秒降到几十毫秒

如果AI调用失败怎么办?

这里会遇到一个新问题:如果AI调用失败,如何处理?

方案1:记录失败状态,不保存数据

public void processDocument(Document doc) {
    DocumentData data = parserService.parse(doc);

    try {
        String summary = aiService.extractSummary(data);
        data.setSummary(summary);

        // AI成功,保存数据
        transactionTemplate.executeWithoutResult(status -> {
            dataService.save(data);
        });
    } catch (Exception e) {
        // AI失败,不保存数据,记录失败状态
        log.error("AI调用失败: {}", e.getMessage());
        taskService.markFailed(doc.getId(), "AI调用失败");
    }
}

方案2:保存基础数据,AI结果后续补充

public void processDocument(Document doc) {
    DocumentData data = parserService.parse(doc);

    // 先保存基础数据(不依赖AI)
    transactionTemplate.executeWithoutResult(status -> {
        dataService.saveBasicData(data);
    });

    // 然后调用AI补充信息(事务外)
    try {
        String summary = aiService.extractSummary(data);
        String keywords = aiService.extractKeywords(data);

        // AI成功,更新补充信息(独立事务)
        transactionTemplate.executeWithoutResult(status -> {
            dataService.updateAiResult(data.getId(), summary, keywords);
        });
    } catch (Exception e) {
        // AI失败,基础数据已保存,AI结果标记为待补充
        log.error("AI调用失败,稍后补充: {}", e.getMessage());
        taskService.markPendingAiResult(data.getId());
    }
}

方案2的好处:

  • 基础数据不会丢失
  • AI失败后可以异步重试补充
  • 用户可以先看到基础信息,稍后看到AI补充的信息

AI调用的不确定性

AI调用还有另一个问题:不确定性

同一个AI模型,同样的输入,两次调用可能得到不同的结果:

  • 第一次调用耗时10秒,返回结果A
  • 第二次调用耗时15秒,返回结果B
  • 第三次调用超时失败

这种不确定性给事务管理带来挑战:

  • 如果放在事务里,每次调用时间不确定,事务持有时间也不确定
  • 如果失败重试,可能导致多次长事务
  • 超时时间难以预估(是设置30秒还是60秒?)

我的建议:

  • AI调用永远放在事务外
  • 设置合理的超时时间(如30秒)
  • 失败后记录状态,异步重试
  • 不依赖AI结果的立即完成性(接受延迟)

本地消息表方案特别适合AI调用

前面提到的"本地消息表"方案,特别适合处理AI调用:

@Transactional
public void processDocument(Document doc) {
    DocumentData data = parserService.parse(doc);

    // 保存基础数据
    dataService.saveBasicData(data);

    // 写入消息表(触发AI处理)
    AgentMessage message = AgentMessage.builder()
        .taskId(data.getId())
        .messageType("AI_PROCESS")
        .messageContent(JSON.toJSONString(data))
        .status("PENDING")
        .build();

    messageService.save(message);  // 同一事务,保证消息不丢失
}

// 定时任务异步处理AI调用
@Scheduled(fixedRate = 10000)
public void processAiMessages() {
    List<AgentMessage> messages = messageService.getPendingMessages();

    for (AgentMessage message : messages) {
        try {
            DocumentData data = JSON.parseObject(message.getContent(), DocumentData.class);

            // AI调用(不在事务里,慢操作)
            String summary = aiService.extractSummary(data);
            String keywords = aiService.extractKeywords(data);

            // AI成功,更新结果(独立事务,快)
            transactionTemplate.executeWithoutResult(status -> {
                dataService.updateAiResult(data.getId(), summary, keywords);
                messageService.markSuccess(message.getId());
            });
        } catch (Exception e) {
            // AI失败,设置重试
            messageService.markFailed(message.getId(), e.getMessage());
        }
    }
}

为什么这个方案适合AI调用?

  1. AI调用不在事务里:慢操作不影响数据库连接
  2. 自动重试机制:AI调用失败后自动重试(指数退避)
  3. 超时处理:AI超时失败也能正确处理
  4. 报警机制:多次失败后报警,人工介入
  5. 不阻塞主流程:基础数据先保存,AI结果异步补充

AI调用超时的影响

还有一个容易被忽略的问题:AI超时导致事务超时

如果AI调用设置了超时(如30秒),而数据库事务也设置了超时(如60秒):

@Transactional(timeout = 60)  // 事务超时60秒
public void processDocument(Document doc) {
    // AI调用超时30秒
    AIResult result = aiService.extractWithTimeout(doc, 30);

    // 如果AI在第40秒才超时(超过了预期)
    // 事务只剩20秒,可能立即超时
    dataService.save(result);  // 事务可能已经超时
}

问题:

  • AI超时时间设置不合理(超过事务超时)
  • AI调用实际耗时超过预期(网络慢、模型慢)
  • 事务在AI调用期间超时,后续数据库操作失败

我的建议:

  • 事务超时时间应该远大于AI超时时间(如事务60秒,AI30秒)
  • 或者干脆不要在事务里调用AI
  • AI调用失败不应该影响事务(应该独立处理)

总结:AI调用导致长事务的要点

要点 说明
AI调用很慢 5-30秒甚至更长,远超数据库操作
容易被忽略 测试环境掩盖、流量小掩盖、直觉误解
事务外处理 所有AI调用都应该放在事务外
不确定性 AI调用时间不确定,结果不确定
本地消息表 最适合处理AI调用(异步+重试+报警)
超时冲突 AI超时可能超过事务超时,需要合理设置
失败处理 AI失败记录状态,异步重试补充

核心原则: AI调用永远不要在事务里,除非你确信它非常快(如本地Mock)。

第一反应的方案:拆分事务?

直觉方案:把大事务拆成多个小事务

我当时的第一反应是:既然长事务有问题,那就拆分呗!

// 拆分成多个独立的小事务
transactionTemplate.executeWithoutResult(status -> {
    structureService.buildStructure(docId, doc.getData());  // 独立事务
});

transactionTemplate.executeWithoutResult(status -> {
    dataService.batchCreateItems(docId, doc.getItems());  // 独立事务
});

for (Item item : doc.getItems()) {
    transactionTemplate.execute(status -> {
        return taskService.createTask(docId, item.getId(), ...);  // 每个任务独立事务
    });
}

看起来合理对吧?每个操作独立事务,锁持有时间短,性能应该好很多。

新问题来了:失败后怎么恢复?

但这个方案有个致命问题:如果中途失败了怎么办?

举个例子:

  • 数据结构创建成功了(第1个事务提交)
  • 数据项创建成功了(第2个事务提交)
  • 第N个子任务创建失败了(数据库异常)
  • 后面的任务都没创建
  • 任务整体标记为失败状态

现在任务处于一个尴尬的状态:部分成功,部分失败

下次重试时:

  • 数据结构已存在(不能再创建)
  • 数据项已存在(不能再创建)
  • 子任务部分存在(需要补齐缺失的)

我们需要判断每一步是否已完成,如果已完成就跳过…这听起来好像可行?

幂等性检查的引入

于是引入了幂等性检查:

// 检查数据结构是否已存在
List<Structure> structures = structureService.getByDocumentId(docId);
if (structures.isEmpty()) {
    // 未完成,执行创建
    transactionTemplate.executeWithoutResult(status -> {
        structureService.buildStructure(docId, doc.getData());
    });
} else {
    // 已完成,跳过
    log.info("数据结构已存在,跳过构建");
}

// 检查数据项是否已存在
List<DataItem> items = dataService.getByDocumentId(docId);
if (items.isEmpty()) {
    // 未完成,执行创建
    transactionTemplate.executeWithoutResult(status -> {
        dataService.batchCreateItems(docId, doc.getItems());
    });
} else {
    // 已完成,跳过
    log.info("数据项已存在,跳过创建");
}

// 补齐缺失的子任务
for (DataItem item : items) {
    Task task = taskService.createOrGetTask(...);  // 幂等方法
}

核心逻辑:查询数据库判断每一步是否完成,已完成就跳过,未完成就执行

这个方案的核心假设是:失败后会有重试机制,重试时能跳过已完成的步骤

RecoveryJob的配合

Agent系统通常都有任务恢复机制:

@Scheduled(fixedRate = 60000)  // 定时扫描超时任务
public void recoverTimeoutTasks() {
    List<DocumentTask> timeoutTasks = taskService.getTimeoutTasks();

    for (DocumentTask task : timeoutTasks) {
        // CAS重置状态为PENDING,准备重试
        if (taskService.markPending(task.getId(), "PROCESSING")) {
            // 重新触发任务
            eventPublisher.publishEvent(new DocumentCreatedEvent(...));
        }
    }
}

重试时,因为幂等性检查的存在,会跳过已完成的步骤,继续执行未完成的步骤。

看起来这个方案可行!

方案的权衡

但这时候我开始纠结了:

优点很明显:

  • 事务粒度小,锁持有时间大幅降低
  • 失败后可以断点续传,不需要从头开始
  • 总耗时增加不大

但也有担忧:

  • "部分成功"的状态是否违背了"失败就完全重来"的设计原则?
  • 幂等性检查是否可靠?如果判断逻辑有bug怎么办?
  • 重试机制是否足够健壮?

我开始查阅设计文档,发现原始设计明确要求"失败就完全重来,不需要清理逻辑"…

设计冲突的思考

这里遇到了一个设计冲突:

原始设计理念:

  • 大事务保证原子性
  • 失败就完全回滚
  • 重试时从头开始(简单、清晰)

新的方案:

  • 小事务拆分
  • 部分成功状态
  • 重试时断点续传(需要幂等性)

我思考了很久,觉得这个冲突的本质是:设计原则 vs 实际问题

原始设计原则是理想化的(“失败就完全重来”),但实际遇到的问题是:长事务导致性能问题。

这时候需要权衡:

  • 如果坚持原始原则,就需要接受长事务的性能代价
  • 如果解决性能问题,就需要接受"部分成功"的设计

我最终的选择:接受"部分成功",因为性能问题更严重。

理由:

  1. 幂等性检查是可靠的(基于数据库记录判断)
  2. RecoveryJob重试机制健壮(有CAS+乐观锁保护)
  3. 性能提升明显(锁持有时间大幅降低)
  4. 失败恢复更优雅(断点续传 vs 完全重来)

方案2:本地消息表(更可靠的方案)

在研究长事务问题时,我还发现了另一个方案:本地消息表

这个方案的思路完全不同:不依赖事务拆分,而是通过"可靠消息"机制保证最终一致性。

方案思路

核心想法:把长事务拆分成多个短事务,每个短事务完成后发送一个"消息",另一个流程异步处理这个消息

主流程(短事务) → 本地消息表 → 异步执行器 → 最终完成

关键点:消息表和主业务在同一事务中写入,保证消息不丢失。

具体实现

第一步:创建消息表
CREATE TABLE agent_message (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    task_id INTEGER NOT NULL,
    message_type VARCHAR(50) NOT NULL,
    message_content TEXT NOT NULL,
    status VARCHAR(20) DEFAULT 'PENDING',  -- PENDING/PROCESSING/SUCCESS/FAILED
    retry_count INTEGER DEFAULT 0,
    max_retry INTEGER DEFAULT 5,
    next_retry_time TEXT,
    error_message TEXT,
    create_time TEXT DEFAULT (datetime('now', 'localtime'))
);
第二步:主流程写入消息(事务内)
@Transactional
public void handleDocumentCreated(DocumentCreatedEvent event) {
    // 解析文档(事务外)
    Document doc = parserService.parse(filePath);

    // 写入消息表(事务内)
    AgentMessage message = AgentMessage.builder()
        .taskId(taskId)
        .messageType("PROCESS_DATA")
        .messageContent(JSON.toJSONString(doc))
        .status("PENDING")
        .build();

    messageService.save(message);  // 与业务操作在同一事务
}

关键:消息写入和主业务在同一事务,要么都成功,要么都失败。

第三步:定时扫描消息并执行
@Scheduled(fixedRate = 10000)  // 定时扫描
public void processPendingMessages() {
    List<AgentMessage> messages = messageService.getPendingMessages();

    for (AgentMessage message : messages) {
        try {
            // 标记为处理中
            messageService.markProcessing(message.getId());

            // 执行实际任务(独立事务)
            executeMessageTask(message);

            // 标记成功
            messageService.markSuccess(message.getId());

        } catch (Exception e) {
            // 处理失败,设置重试
            messageService.markFailed(message.getId(), e.getMessage());

            // 达到最大重试次数时报警
            if (message.getRetryCount() >= message.getMaxRetry()) {
                sendAlert(message);
            }
        }
    }
}
第四步:重试机制(指数退避)
public void markFailed(Long messageId, String errorMessage) {
    AgentMessage message = getById(messageId);
    message.setRetryCount(message.getRetryCount() + 1);
    message.setErrorMessage(errorMessage);

    if (message.getRetryCount() < message.getMaxRetry()) {
        // 指数退避:10s, 20s, 40s, 80s...最大300s
        int delay = Math.min(10 * (1 << message.getRetryCount()), 300);
        message.setNextRetryTime(LocalDateTime.now().plusSeconds(delay));
        message.setStatus("PENDING");
    } else {
        message.setStatus("FAILED_PERMANENTLY");
    }
}

为什么要指数退避?

  • 避免重试风暴:如果大量消息同时失败,同时重试会冲击系统
  • 给系统恢复时间:第一次失败可能是临时问题(网络抖动),等一会儿可能就好了
  • 减少资源浪费:频繁重试消耗资源,指数退避更合理
第五步:报警机制
private void sendAlert(AgentMessage message) {
    String content = "任务永久失败报警:\n" +
        "- 任务ID: " + message.getTaskId() + "\n" +
        "- 错误信息: " + message.getErrorMessage() + "\n" +
        "- 需要人工介入处理";

    // 多种报警方式
    emailService.sendAlertEmail(content);
    slackService.sendAlert(content);
    log.error("任务永久失败: {}", content);
}

这个方案的好处

最大的好处:可靠性。

  • 消息表和主事务在一起,保证消息不丢失
  • 自动重试机制,应对临时故障
  • 指数退避,避免重试风暴
  • 报警机制,及时发现异常

另一个好处:解耦。

  • 主流程不需要等待长操作完成,直接返回
  • 执行器异步处理,不阻塞主流程
  • 系统吞吐量更高

但也有代价:

  • 实现复杂度增加(消息表、定时任务、重试逻辑)
  • 最终一致性(不是立即完成,而是"保证最终完成")
  • 需要额外的数据库表和定时任务

适用场景

我开始思考:什么时候该用这个方案?

适合的场景:

  • 外部API调用(AI模型、网络服务)——这些不稳定,需要重试
  • 跨服务调用——网络通信可能失败
  • 关键业务流程——必须保证最终完成

不太适合的场景:

  • 纯数据库操作——本地操作相对稳定,失败概率低
  • 批量数据处理——不需要复杂重试逻辑
  • 性能要求高的场景——消息表机制会增加延迟

方案对比:到底选哪个?

经过这次踩坑和研究,我总结了两种方案的特点:

方案对比

特性 事务拆分+幂等性 本地消息表
核心思路 拆分长事务,重试跳过已完成步骤 可靠消息,异步执行+重试
性能提升 明显(锁持有时间大幅降低) 有限(增加异步处理延迟)
可靠性 依赖幂等性检查 更高(消息表+重试+报警)
实现复杂度 中等(幂等性逻辑) 较高(消息表+定时任务+重试)
失败恢复 断点续传(跳过已完成) 自动重试(从消息恢复)
适用场景 本地数据库操作 外部调用、跨服务

我的最终选择

对于文档处理这种场景,我选择了事务拆分+幂等性方案。

理由:

  1. 主要操作是数据库写入——相对稳定,不需要复杂重试机制
  2. 性能需求高——用户上传文档后希望尽快处理完成
  3. 实现相对简单——只需要幂等性检查逻辑,不需要消息表
  4. RecoveryJob机制健壮——有CAS+乐观锁保护,重试可靠

如果场景不同,我会选择本地消息表:

  • 如果调用了外部AI服务(不稳定,需要重试+报警)
  • 如果是跨服务调用(需要可靠消息机制)
  • 如果是关键业务流程(必须保证最终完成)

实际踩坑经验和注意事项

1. 幂等性检查的坑

踩坑:开始想的判断逻辑不够准确

一开始我想用"任务状态"来判断是否完成,但发现有问题:

  • 任务状态是"PROCESSING"时,可能已经部分完成
  • 单靠状态判断不够准确

正确的做法:查询具体的数据记录

// 正确:查询具体数据
List<Structure> structures = structureService.getByDocumentId(docId);
if (structures.isEmpty()) {
    // 未完成
}

// 错误:只查任务状态
DocumentTask task = taskService.getById(docId);
if (task.getStatus() != "COMPLETED") {
    // 无法判断是否部分完成
}

2. 并发恢复的坑

踩坑:担心并发恢复会导致重复创建

我开始担心:如果RecoveryJob并发恢复同一个任务,会不会重复创建数据?

实际发现:RecoveryJob已经有并发保护

@Scheduled(fixedRate = 60000)
public void recoverTimeoutTasks() {
    for (DocumentTask task : timeoutTasks) {
        // CAS重置状态,只有一个线程能成功
        if (!taskService.markPending(task.getId(), "PROCESSING")) {
            log.info("任务已被其他流程处理,跳过");
            continue;  // 其他线程跳过
        }
    }
}

markPending方法有CAS检查和乐观锁保护:

@Transactional
public boolean markPending(Long taskId, String originalStatus) {
    DocumentTask task = getById(taskId);

    // CAS检查:状态是否匹配
    if (!task.getStatus().equals(originalStatus)) {
        return false;  // 其他线程已经修改了状态
    }

    // 乐观锁更新:version字段保护
    boolean success = updateById(task);
    return success;  // 只有version匹配才能成功
}

结论:RecoveryJob加上CAS+乐观锁,并发恢复风险不存在。

3. 性能测量的坑

踩坑:直觉认为拆分事务会增加总耗时

我一开始担心:拆分成多个小事务会不会增加总耗时?

实际测量:总耗时增加不大

原因分析:

  • 批量插入本身很快,拆分不会显著增加时间
  • 事务管理 overhead 主要在锁等待,拆分反而减少等待
  • 性能瓶颈在文件解析(事务外),事务拆分影响不大

关键指标是锁持有时间,这才是真正影响其他请求的因素。

4. 子任务批量创建的坑

踩坑:每个子任务独立事务可能太多

我开始担心:

  • 数据库连接池压力
  • 事务管理开销

权衡:要不要改成批量事务?

思考后,决定保持独立事务:

  • 每个子任务插入很快,影响不大
  • 独立事务的好处:失败后只影响单个任务,可以精确重试
  • 如果批量事务失败,需要判断哪些任务已创建(更复杂)

5. 设计文档冲突的坑

踩坑:新方案与设计文档理念冲突

设计文档明确要求"失败就完全重来",但新方案是"部分成功+断点续传"。

处理方式:更新设计文档,说明新方案的理由

设计变更说明:

  • 原始设计:大事务保证原子性(理想化)
  • 实际问题:长事务导致性能瓶颈(现实)
  • 新方案:小事务+幂等性(折中)
  • 理由:性能问题更严重,幂等性机制可靠

性能实测数据(相对对比)

为了验证方案效果,我测试了三种方案的相对性能:

测试场景对比

方案 总耗时 事务锁持有时间 失败恢复时间 实现复杂度
大事务方案 基准时长 很长(基准) 很长(完全重来) 简单
事务拆分方案 略有增加 大幅降低(20%) 较短(断点续传) 中等
本地消息表方案 有一定增加 很短(5%) 自动重试 较高

关键发现:

  • 总耗时差异不大,性能瓶颈在文件解析
  • 关键指标是锁持有时间,这才是影响其他请求的关键
  • 失败恢复时间:断点续传明显更快

实际效果:

  • 事务拆分方案上线后,数据库连接池报警消失
  • 其他用户的请求不再被阻塞
  • 系统整体响应时间改善

总结和建议

核心经验

这次踩坑让我明白了几个道理:

  1. 不要迷信"大事务保证一致性"——一致性很重要,但性能问题同样严重
  2. 数据一致性 != 事务一致性——可以通过幂等性、重试机制保证最终一致性
  3. 性能问题的本质是锁持有时间——不是总耗时,而是锁阻塞其他请求的时间
  4. 方案选择要看具体场景——没有银弹,根据实际情况权衡

给其他团队的建议

如果你也遇到长事务问题,建议这样处理:

第一步:分析问题根源

  • 测量事务持有锁的时间(不是总耗时)
  • 确认是否真的阻塞了其他请求
  • 确定哪些操作在事务内,哪些可以移到事务外

第二步:选择方案

  • 纯数据库操作:优先考虑事务拆分+幂等性
  • 外部调用:优先考虑本地消息表
  • 混合场景:组合使用两种方案

第三步:实现幂等性

  • 查询具体数据记录判断是否完成
  • 不要只依赖状态字段判断
  • 确保重试机制健壮(RecoveryJob)

第四步:测试验证

  • 测量性能改善(锁持有时间、其他请求响应时间)
  • 测试失败恢复(断点续传是否正常)
  • 测试并发场景(RecoveryJob并发恢复)

未来可能遇到的问题

现在方案上线运行正常,但我还在思考可能的问题:

潜在问题:

  • 幂等性判断逻辑如果有bug怎么办?
  • 如果数据库异常导致查询失败怎么办?
  • 如果RecoveryJob本身失败了怎么办?

应对思路:

  • 添加日志详细记录每一步判断结果(方便排查)
  • 添加监控检测异常状态(部分成功但未完成)
  • 保留人工干预接口(手动重试或清理)

这些问题不会立刻出现,但值得持续关注。


最后说一句:技术方案没有银弹,关键是根据实际情况权衡选择。

这次踩坑的经历让我对事务管理有了更深的理解,也希望这篇分享能帮到同样遇到长事务问题的同学。

如果你有更好的方案或者踩坑经验,欢迎交流讨论。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐