尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

接口调了4次都失败?Spring Boot任务状态与幂等性排查实战

接口调了4次都失败?Spring Boot任务状态与幂等性排查实战 “什么叫你打了 4 次 lumi 都没打过”第一次看到这句话我也愣了一下。后来才发现这里的“打”不是游戏里的“打 Boss”而是后端联调中常说的“打接口”lumi 也不是某个角色名而是业务系统里的一个内部任务代号。更准确地说它通常指一个批量触发、数据同步或外部服务回写任务。连续调用 4 次都没有真正生效说明问题大概率不是因为运气差而是接口设计、幂等控制、事务边界或日志排查上出了系统性漏洞。这篇文章就围绕这个场景展开。我会先解释这种“连续多次调用仍失败”的问题究竟属于哪类技术问题再给出一个最小可复现的后端示例工程用代码来演示如何设计任务接口、如何记录执行状态、如何判断一次任务到底有没有真正成功。你不需要知道 lumi 具体是某套业务系统里的什么服务你只需要把它理解成一个你正在排障的内部服务代号。搞懂排查思路之后可以把 lumi 替换成你自己的 Batch 任务、RPC 接口或消息队列消费者。1. 为什么会出现“连续打了 4 次都没打过”的问题1.1 lumi 只是一个占位服务问题本质是“看起来成功实际没成功”在真实的后端系统里任务执行失败并不是只有“接口直接报错”一种表现。更常见的情况是调用方收到 HTTP 200接口日志里也打印了任务执行成功但最后查询业务结果时发现数据没有变化。于是调用方只能再提交一次第二次又看到“成功”再查业务数据还是没变化。这样反复几次同事就会忍不住问什么叫你打了 4 次 lumi 都没打过之所以会出现这种局面通常是因为调用方把“请求送达服务端”和“业务处理成功”这两件事混为一谈。请求成功提交到服务端只代表 HTTP 请求被 Controller 接收了不代表业务状态从 INIT 变成 SUCCESS也不代表数据库里那条记录被正常更新。真正的成功标准应该是核心业务数据发生了预期变化并且这个变化可被查询、可被审计、可被重复验证。1.2 这类问题通常涉及哪些核心矛盾连续多次调用仍失败问题往往不是单点原因而是多层因素叠加。从调用方看可能是重试策略不合理、请求参数每次都不一样、没有判断返回值从服务提供方看可能是任务执行没有做状态持久化、没有做幂等、异常被 try-catch 吞掉后返回成功从基础设施看可能是分布式锁没锁住、事务边界开得过大、缓存和数据库数据不一致、消息队列重复消费。换句话说这不是一个简单的“多打几次就能成功”的问题。如果每次请求都没有留下可追踪的执行记录那么第 4 次和第 1 次本质上没有区别都是在重复踩同一个坑。正确做法是先把第 1 次失败的真实原因找出来再决定是否允许自动重试、如何防止重复执行、如何让下一次执行能真正成功。2. 示例场景与排错环境准备2.1 一个可复现的四次失败场景假设业务系统里有一个内部任务代号叫 lumi它的作用是根据调用方传入的 requestId执行一次资产数据同步。调用方每次提交任务时都会生成一个新的 requestId期望最终能把某个业务表的状态更新为 SUCCESS。第一次调用任务接口返回 200但数据库中没有任何记录被创建。原因是 lumi 的核心业务处理抛出了异常但异常在 Controller 层被一个比较宽泛的 catch 语句捕获后返回了一个固定的 success 标记。第二次调用调用方换了新的 requestId接口还是返回 200但状态表只插入了一条 RUNNING 记录没有更新成 SUCCESS。原因是任务里开启了数据库事务执行异常后整个事务回滚RUNNING 记录也随之消失。第三次调用更换了锁的过期时间但发现任务被并发触发两个请求同时进入数据库唯一索引拦截了其中一个请求而拦截后的异常没有被转换为可理解的错误提示。第四次调用调用方终于看到了明确的失败信息但还不知道怎么自动恢复。这个场景看起来很复杂但拆解之后技术点非常集中任务状态机设计、幂等控制、事务边界、异常处理和可观测性。下面我们围绕这些点展开。2.2 技术栈说明与版本原则本文示例以 Java 17 和 Spring Boot 3.x 为基础数据库使用 MySQL 8.x缓存和分布式锁相关的讨论会结合 Redis 展开。不过本文的核心并不绑定某个具体版本如果你使用的是 Spring Boot 2.7.x 或 JDK 11大部分代码思路同样适用。版本差异主要存在于部分依赖的命名和自动配置类上遇到问题时优先以你当前项目的依赖版本为准。项目的关键依赖包括spring-boot-starter-web 提供 HTTP 接口能力spring-boot-starter-jdbc 负责数据库访问mysql-connector-j 驱动连接 MySQL。下面的示例中我还会用到 Redis 做锁演示但即使你没有 Redis也可以先用数据库唯一索引完成幂等控制。2.3 项目结构说明为了便于阅读我们约定项目结构如下src/main/java/com/example/lumi/ ├── LumiApplication.java ├── controller/ │ └── LumiTaskController.java ├── service/ │ └── LumiTaskService.java └── model/ └── LumiRunRequest.java如果你已经熟练使用 Spring Boot也可以把类名改成你自己项目里的命名风格。重点是理解 Service 层中状态处理的思想而不是照搬包名。3. 一次调用失败后优先排查哪四层原因3.1 请求真的到达服务端了吗先把最简单的可能排除掉。当调用方说“我打了 4 次 lumi 都没打过”时第一步不是查业务代码而是确认这 4 次请求是否真的到达了 lumi 服务端。可以使用操作系统的网络排查命令查看端口连通性也可以在 lumi 服务端打印一条请求入口日志记录请求路径、请求参数和请求时间。由于文章主题不是网络排查这里不展开抓包细节但这个步骤不能跳过。很多长时间排查无果的问题最后发现是网关路由规则写错了请求根本没有转发到 lumi 服务上。调用方看到的 200 响应来自网关的默认响应而不是 lumi 业务代码的返回结果。这也是“打了 4 次都没打过”的常见原因之一你打的对象可能从一开始就错了。3.2 服务端真的执行成功了吗确认请求到达服务端后继续查服务端日志。这里要看三个关键点第一Controller 方法是否入参正确第二Service 方法是否抛出了异常第三异常是否被某个全局异常处理器或 try-catch 代码块吞掉。较常见的错误写法是try { lumiTaskService.run(request); return Result.success(); } catch (Exception e) { log.error(lumi 任务执行失败, e); return Result.success(); }这段代码的问题很明显异常已经被日志记录下来但接口仍然返回成功。如果调用方只看 HTTP 状态码和返回体里的 success 字段会误以为任务已经执行成功。这种“吞异常返回成功”的做法在联调和排障阶段会带来极大的误导。正确的做法是遇到异常时返回明确的失败状态至少要让调用方知道这次调用没有成功。3.3 结果真的反馈给调用方了吗即便服务端已经成功处理完业务也可能因为返回结构设计不合理导致调用方无法判断成功。比如任务接口设计成异步模式提交后立即返回“已受理”但真正执行是在后台线程中完成。这时候如果调用方不理解异步语义就会以为“受理成功”等于“业务成功”。所以建议在接口返回结构中明确区分两种状态taskStatus: ACCEPTED这种状态表示任务已经被接收但仍需要后续主动查询任务结果。对于同步任务则可以直接返回 SUCCESS 或 FAILED。如果你正在排障“连续多次调用不生效”先检查接口返回的状态语义是否被调用方正确理解再看 Service 内部的最终业务表状态。3.4 为什么重复执行后依然失败如果多次请求每次都生成了不同的 requestId每次都返回成功但数据库里仍然没有正确结果那就要考虑是否是同一个底层原因在反复触发。举例来说如果 lumi 任务执行前需要查询一个配置表而这个配置表里缺少必要数据那么换多少 requestId执行多少次都会在同一个位置失败。此时不停重试没有意义应该先补全前置数据或者把失败原因在日志和返回结果里完整暴露出来。从工程经验看一个任务连续失败多次最值得警惕的是每次失败都发生在同一个状态。这时要找出失败位置的共同点而不是继续发起第 5 次调用。4. 手把手写一个最小可运行的 lumi 任务接口4.1 需求定义下面我们实现一个简化版 lumi 任务接口。接口接收两个参数requestId 和 operator。requestId 是本次任务的幂等键operator 记录操作人。接口每次被调用时需要先检查 requestId 是否已经成功执行如果已经成功直接返回成功如果没有执行过则插入一条 RUNNING 记录然后执行业务逻辑业务执行成功后将状态更新为 SUCCESS业务执行失败则把状态更新为 FAILED并记录错误信息方便下一次重试。为了避免代码过长导致理解困难这里不引入 Redis 锁只使用数据库唯一索引做幂等控制。真实项目中如果你需要防止多个线程同时处理同一个 requestId可以在此基础上增加 Redis 分布式锁。先掌握数据库幂等再扩展锁会更轻松。4.2 数据库表设计与 SQL先创建 lumi 任务的执行记录表。这张表是判断“到底有没有打过”的核心依据CREATE TABLE lumi_exec_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, request_id VARCHAR(64) NOT NULL COMMENT 请求幂等ID, operator VARCHAR(64) NOT NULL COMMENT 操作人, status VARCHAR(16) NOT NULL COMMENT 状态RUNNING/SUCCESS/FAILED, error_msg VARCHAR(500) DEFAULT NULL COMMENT 失败原因, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, UNIQUE KEY uk_request_id (request_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT lumi任务执行记录表;表结构的关键点是 request_id 上的唯一索引。这个唯一索引可以防止同一个 requestId 被并发重复执行。status 字段用来记录任务从开始到结束的完整生命周期。4.3 Spring Boot 配置文件在 src/main/resources/application.yml 中配置数据源server: port: 8080 spring: application: name: lumi-demo datasource: url: jdbc:mysql://localhost:3306/lumi_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your-password driver-class-name: com.mysql.cj.jdbc.Driver这里需要替换成你自己的数据库地址、账号和密码。如果你的 MySQL 不是 8.xdriver-class-name 可能需要改成旧版驱动。第 4.2 节创建的表在当前数据库中已经存在后才能继续运行接口。4.4 Lumi 任务 Service 核心代码在 src/main/java/com/example/lumi/service/LumiTaskService.java 中加入如下核心代码package com.example.lumi.service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import java.util.Objects; Service public class LumiTaskService { private static final Logger log LoggerFactory.getLogger(LumiTaskService.class); private static final long RUNNING_TIMEOUT_SECONDS 120L; private final JdbcTemplate jdbcTemplate; public LumiTaskService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } public void run(String requestId, String operator) { String status queryStatus(requestId); if (SUCCESS.equals(status)) { log.info(幂等命中requestId{} 已成功直接返回, requestId); return; } if (RUNNING.equals(status)) { if (isRunningTimeout(requestId)) { log.warn(requestId{} 执行超时标记为失败, requestId); markFailed(requestId, running timeout, please retry with new requestId); } else { throw new IllegalStateException(requestId requestId 正在执行中请勿重复提交); } } try { insertRunningRecord(requestId, operator); } catch (DuplicateKeyException e) { String currentStatus queryStatus(requestId); if (SUCCESS.equals(currentStatus)) { log.info(重复请求但 requestId{} 已成功直接返回, requestId); return; } throw new IllegalStateException(requestId requestId 已存在状态为 currentStatus, e); } log.info(lumi 任务开始执行requestId{}, requestId); try { executeLumiCoreTask(requestId); } catch (Exception e) { log.error(lumi 任务执行失败requestId{}, requestId, e); markFailed(requestId, e.getMessage()); throw e; } markSuccess(requestId); log.info(lumi 任务执行成功requestId{}, requestId); } private void executeLumiCoreTask(String requestId) { // 这里替换成真实业务逻辑比如同步数据、生成报表、调用远程服务。 // 为了示例方便这里只做一件事如果是 mock-fail 开头的 requestId就触发失败。 if (requestId ! null requestId.startsWith(mock-fail)) { throw new RuntimeException(mock business exception); } } private String queryStatus(String requestId) { try { return jdbcTemplate.queryForObject( SELECT status FROM lumi_exec_record WHERE request_id ?, String.class, requestId); } catch (EmptyResultDataAccessException e) { return null; } } private boolean isRunningTimeout(String requestId) { try { Long seconds jdbcTemplate.queryForObject( SELECT TIMESTAMPDIFF(SECOND, create_time, NOW()) FROM lumi_exec_record WHERE request_id ?, Long.class, requestId); return seconds ! null seconds RUNNING_TIMEOUT_SECONDS; } catch (EmptyResultDataAccessException e) { return false; } } private void insertRunningRecord(String requestId, String operator) { jdbcTemplate.update( INSERT INTO lumi_exec_record(request_id, operator, status, error_msg) VALUES (?, ?, RUNNING, NULL), requestId, operator); } private void markSuccess(String requestId) { jdbcTemplate.update( UPDATE lumi_exec_record SET status SUCCESS, error_msg NULL WHERE request_id ?, requestId); } private void markFailed(String requestId, String errorMsg) { String msg Objects.toString(errorMsg, unknown error); if (msg.length() 500) { msg msg.substring(0, 500); } jdbcTemplate.update( UPDATE lumi_exec_record SET status FAILED, error_msg ? WHERE request_id ?, msg, requestId); } }这段代码就是我们解决“打了多次都没打过”的核心。它做了三件非常重要的事情第一每次真正执行任务前先查询 requestId 是否已经成功过第二执行前插入 RUNNING 记录借助数据库唯一索引防止并发重复处理第三业务执行失败时记录 FAILED 和错误原因而不是让异常被静默吞掉。这里没有给 run 方法加 Transactional这是有意为之。因为如果任务执行失败我们不希望 RUNNING 记录随着事务回滚一起消失。我们需要的是把 RUNNING 改为 FAILED方便下次重试时判断。如果你一定要在方法上加事务要特别注意异常和状态更新的顺序否则会得到“看起来回滚了其实状态记录还没落库”的尴尬结果。4.5 Controller 和请求模型代码在 src/main/java/com/example/lumi/controller/LumiTaskController.java 中加入如下代码package com.example.lumi.controller; import com.example.lumi.model.LumiRunRequest; import com.example.lumi.service.LumiTaskService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; RestController RequestMapping(/internal/lumi) public class LumiTaskController { private static final Logger log LoggerFactory.getLogger(LumiTaskController.class); private final LumiTaskService lumiTaskService; public LumiTaskController(LumiTaskService lumiTaskService) { this.lumiTaskService lumiTaskService; } PostMapping(/run) public MapString, Object run(RequestBody LumiRunRequest request) { try { lumiTaskService.run(request.requestId(), request.operator()); return Map.of( success, true, requestId, request.requestId(), message, task finished); } catch (Exception e) { log.error(requestId{} 执行异常, request.requestId(), e); return Map.of( success, false, requestId, request.requestId(), message, e.getMessage()); } } }请求模型 LumiRunRequest 使用 Java Record 定义package com.example.lumi.model; public record LumiRunRequest(String requestId, String operator) { }Controller 的职责是接收请求和返回结果。这里虽然也在 catch 中捕获了异常但注意两点第一日志中打印了完整异常栈方便后续定位第二返回体中的 success 字段是 false调用方不会误判为成功。4.6 运行与验证启动 Spring Boot 应用后先模拟一次成功调用curl -X POST http://localhost:8080/internal/lumi/run \ -H Content-Type: application/json \ -d {requestId:d1a2b3c4-0001,operator:csdn-demo}预期返回{success:true,requestId:d1a2b3c4-0001,message:task finished}查询数据库SELECT request_id, status, error_msg, create_time, update_time FROM lumi_exec_record WHERE request_id d1a2b3c4-0001;可以看到一条状态为 SUCCESS 的记录。此时再次调用同一个 requestId不会重复执行业务逻辑而是直接幂等命中返回成功。再模拟一次失败调用使用 mock-fail 开头的 requestIdcurl -X POST http://localhost:8080/internal/lumi/run \ -H Content-Type: application/json \ -d {requestId:mock-fail-0002,operator:csdn-demo}预期返回{success:false,requestId:mock-fail-0002,message:mock business exception}数据库中的记录状态为 FAILEDerror_msg 中记录了具体失败原因。这就比“接口返回 200但数据库什么都没变”要友好得多。5. 连续四次失败的根因排查清单如果你手头的 lumi 任务也出现了“连打 4 次都没打过”的情况不要盲目发起第 5 次请求。建议按下面这个清单逐项排查问题现象常见原因解决思路接口返回 200但数据库没有新记录请求未到达服务端或异常被吞掉后返回固定成功查看服务端入口日志检查是否经过网关路由数据库有 RUNNING 记录但任务最终没成功执行过程中抛异常且状态未更新为 FAILED在异常处理中主动调用 markFailed并抛出异常给调用方同一个 requestId 被重复发送时并发执行只做业务判断没有数据库唯一索引或分布式锁增加唯一索引配合状态查询实现幂等重复点击任务提交按钮后出现大量异常提示前一个请求仍在执行中状态为 RUNNING将 RUNNING 理解为“处理中”提示用户稍后查询任务结果第 4 次调用仍然失败且失败位置完全相同前置数据缺失或配置不完整重试无效分析错误日志中的共同点修复前置条件后再重试调用方不知道任务成功还是失败返回结果中没有区分 ACCEPTED、SUCCESS、FAILED统一接口返回值明确任务终态这个清单并不是万能的但可以覆盖大多数任务接口联调场景。遇到复杂问题时应当先定位到具体的失败堆栈再结合数据库状态和日志链路一起判断。6. 生产环境中的工程建议6.1 幂等设计是任务接口的底线只要任务接口可能被调用方重试就必须考虑幂等。常见的幂等方案有三种数据库唯一索引、Redis 分布式锁、状态机前置判断。本文示例使用的是数据库唯一索引 状态判断这种方式对大多数任务型接口都足够。唯一需要注意的是requestId 的生成规则要保证全局唯一。一般建议使用 UUID、雪花 ID 或“业务类型 业务主键 时间戳”的组合避免不同业务之间出现相同 requestId。如果你需要更强的并发控制可以在插入 RUNNING 记录前使用 Redis 的 SETNX 命令加锁Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(10)); if (!Boolean.TRUE.equals(locked)) { throw new IllegalStateException(任务执行中请勿重复提交); } try { lumiTaskService.run(requestId, operator); } finally { String currentValue stringRedisTemplate.opsForValue().get(lockKey); if (requestId.equals(currentValue)) { stringRedisTemplate.delete(lockKey); } }这里删除锁之前要先比较 value 是否是当前线程写入的 requestId防止误删其他线程的锁。锁的过期时间要根据任务最大执行时间设置不能设置得过短否则任务还没执行完锁就自动过期了。不过 Redis 分布式锁只是补充手段数据库唯一索引仍然是最可靠的兜底。6.2 不要把所有逻辑都包进一个大事务很多开发者习惯在 Service 方法上直接加 Transactional然后在这个方法里调用远程接口、执行耗时的数据分析、或者同步数据。这种做法很容易导致数据库连接长时间占用并且当远程调用失败时整个事务回滚导致已经写入的状态记录也一起消失。对于任务类接口更推荐把状态流转拆成独立的数据操作先写 RUNNING再执行业务最后写 SUCCESS 或 FAILED。每一步之间不强行使用同一个事务而是通过状态字段和错误日志保证数据可追踪、可恢复。当然如果业务本身要求多个数据库表必须原子更新那么事务仍然必不可少但远程调用和长耗时操作应该放在事务之外。6.3 失败重试要设计退避策略而不是无脑重试如果 lumi 第 1 次失败后你着急地打了第 2 次、第 3 次、第 4 次大概率会得到相同的结果。真正合理的重试策略是先等待一小段时间再重试一次如果仍然失败则等待更长时间或者直接把任务放入失败队列由后台任务统一处理。指数退避是常见的实现方式。以调用方为例可以在捕获失败后使用简单的指数退避第 1 次失败后等待 1 秒 第 2 次失败后等待 2 秒 第 3 次失败后等待 4 秒 第 4 次失败后等待 8 秒重试次数建议限制在 3 到 5 次以内。超过重试上限后应当告警人工介入而不是无限重试。如果任务是幂等的重试不会造成重复数据如果任务不是幂等的必须先把幂等做完善再允许重试。6.4 日志和监控要做到可串联排障“打了 4 次 lumi 都没打过”时最怕的是 4 次请求的日志散落在不同服务中无法串联。建议在接口入口生成或者透传 traceId并在日志中打印 requestId。调用方提交请求时也可以把 requestId 放到请求参数中这样整个链路的日志都能通过 requestId 搜索出来。除了日志还要对任务状态进行监控。比如每分钟扫描一次 lumi_exec_record 表中超过 5 分钟仍然是 RUNNING 的记录将它们判断为超时任务并发告警。这样的监控可以发现那些“接口已经返回成功但状态没有走到终态”的隐蔽问题。6.5 生产环境变更要谨慎如果你是在生产环境中排查问题涉及数据库表结构调整、批量修改历史数据或重新触发失败任务之前一定要先评估影响范围。建议在测试环境完整复现问题后再对生产环境操作。修改数据前要备份触发批量任务前要确认任务接口具备幂等能力避免重复执行造成不可逆的数据变更。涉及人工执行 SQL 更新时尽量使用事务包裹并且在更新前先 SELECT 确认影响行数与预期一致。7. 写在最后回顾整篇文章核心想表达的东西并不复杂判断一次任务是否成功不能只看 HTTP 返回码也不能只看
返回列表