尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

微服务架构实战:Feign深度配置与Seata分布式事务集成指南

微服务架构实战:Feign深度配置与Seata分布式事务集成指南 1. 从单体到微服务一次真实的架构演进心路做全栈项目从单体架构一路走到微服务这几乎是每个后端开发者技术成长路上的必经之路。我最近就在折腾一个“仿小红书”的项目之前已经完成了基础的微服务拆分把用户、内容、互动这些模块都独立成了服务。但拆分只是第一步真正的挑战在于拆分之后服务之间怎么优雅、可靠地“对话”。今天这篇我想重点聊聊在微服务架构改造的第二阶段我们是如何解决服务间通信和分布式事务这两个核心难题的特别是围绕 Feign 和 Seata 这两个核心组件的实战落地。很多教程会告诉你 Feign 怎么声明接口Seata 怎么加个注解但实际跑起来你会发现坑一个接一个。比如Feign 调用超时了怎么办服务A调BB又调C这个调用链怎么追踪更头疼的是分布式事务用户发帖要扣积分发帖成功但积分没扣或者反过来这种数据不一致怎么处理靠业务代码去“补偿”和“回滚”代码会变得极其臃肿且难以维护。所以这次改造的核心目标很明确第一建立一套声明式、可维护、具备容错能力的服务间远程调用机制第二引入一个相对轻量、对代码侵入性小的分布式事务解决方案保证核心业务场景的数据最终一致性。我们技术栈选的是 Spring Boot Spring Cloud Alibaba 这一套Nacos 做注册和配置中心Feign 做声明式 HTTP 客户端Seata 来处理分布式事务。听起来都是标准答案但魔鬼全在细节里。2. Feign 的深度配置与最佳实践远不止一个FeignClient在微服务里服务间调用是血脉。Spring Cloud OpenFeign 把 HTTP 调用变成了像调用本地方法一样简单一个FeignClient注解就搞定了。但如果你只停留在这一步线上迟早会出问题。我们得把它从一个“能用”的工具变成“好用且可靠”的基础设施。2.1 基础定义与契约优先首先定义 Feign 客户端接口。这里我强烈建议采用“契约优先”的原则。也就是说先定义好 API 的请求响应模型DTO和接口契约放在一个独立的api模块或client模块中然后服务提供方和调用方都依赖这个模块。// 在独立的 user-service-client 模块中 // 1. 定义DTO Data public class UserInfoDTO { private Long userId; private String nickname; private String avatarUrl; } // 2. 定义Feign客户端接口 FeignClient(name user-service, path /api/user) public interface UserServiceClient { GetMapping(/{userId}) ResultUserInfoDTO getUserById(PathVariable(userId) Long userId); PostMapping(/batch) ResultMapLong, UserInfoDTO getUsersByIds(RequestBody ListLong userIds); }这样做的好处是DTO 和接口定义是共享的避免了调用方自己瞎猜字段名和类型也保证了双方的一致性。UserServiceClient接口本身并不包含实现它只是一个契约。在内容服务里我们只需要引入这个user-service-client依赖然后像注入普通 Bean 一样使用它。// 在内容服务中 Service public class PostService { Autowired private UserServiceClient userServiceClient; public PostDetailVO getPostDetail(Long postId) { Post post postMapper.selectById(postId); // 通过Feign调用用户服务 ResultUserInfoDTO userResult userServiceClient.getUserById(post.getAuthorId()); // 组装VO... } }2.2 超时、重试与熔断降级配置这是 Feign 配置的核心区直接关系到系统的可用性。默认配置在生产环境下是绝对不够的。连接超时与读取超时Feign 底层默认使用 JDK 的HttpURLConnection其超时时间是无限的这非常危险。我们必须显式配置。在 Spring Cloud 2020 之后的版本推荐使用spring.cloud.openfeign.client.config进行配置。# application.yml 中针对特定服务或默认配置 spring: cloud: openfeign: client: config: default: # 默认配置对所有Feign客户端生效 connectTimeout: 3000 # 连接超时 3秒 readTimeout: 10000 # 读取超时 10秒 loggerLevel: basic # 日志级别调试用 user-service: # 针对user-service的特定配置 connectTimeout: 5000 readTimeout: 15000为什么这么设连接超时connectTimeout指建立TCP连接的时间网络正常时这个时间很短设3-5秒足够。读取超时readTimeout指从连接建立成功到收到响应数据的时间这取决于下游服务的处理逻辑。对于简单的查询10秒可能够了对于复杂操作可能需要更长。你需要根据下游服务的性能 SLA 来定。重试机制网络抖动是常态一次调用失败就报错体验太差。Feign 默认不重试我们需要配置一个Retryer。但要注意重试只对幂等操作GET、PUT、DELETE是安全的对于非幂等操作POST要非常小心或者直接禁用重试。Configuration public class FeignConfig { Bean public Retryer feignRetryer() { // 重试间隔100ms最大重试间隔1s最大重试次数3次加上第一次调用共4次 return new Retryer.Default(100, TimeUnit.SECONDS.toMillis(1), 3); } }熔断降级当某个服务持续超时或失败再不断地重试和调用只会雪上加霜拖垮调用方。我们需要快速失败并执行降级逻辑。这里我们整合 Sentinel 或 Hystrix。以 Sentinel 为例首先引入依赖然后在 Feign 配置中启用 Sentinel。feign: sentinel: enabled: true接着为你的 Feign 客户端接口编写一个降级实现类FallbackFactory它可以在熔断时返回一个托底数据。Component public class UserServiceClientFallbackFactory implements FallbackFactoryUserServiceClient { Override public UserServiceClient create(Throwable cause) { return new UserServiceClient() { Override public ResultUserInfoDTO getUserById(Long userId) { // 记录日志告警 log.error(调用用户服务获取用户信息失败, userId: {}, userId, cause); // 返回一个兜底数据比如一个默认用户 UserInfoDTO defaultUser new UserInfoDTO(); defaultUser.setUserId(userId); defaultUser.setNickname(用户暂不可用); return Result.success(defaultUser); } // ... 其他方法的降级实现 }; } } // 在Feign客户端接口上指定 FeignClient(name user-service, path /api/user, fallbackFactory UserServiceClientFallbackFactory.class) public interface UserServiceClient { ... }这样当用户服务不可用时内容服务在获取作者信息时不会一直阻塞等待或抛异常导致整个帖子详情页挂掉而是展示一个友好的默认信息保证了核心流程的可用性。2.3 全局拦截器与请求传递在微服务调用链中我们经常需要传递一些上下文信息比如追踪链路用的traceId、用户登录态的token、当前租户ID等。通过 Feign 的请求拦截器可以优雅地实现。Component public class FeignRequestInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { // 从当前请求上下文如ThreadLocal中获取traceId String traceId MDC.get(traceId); if (StringUtils.isNotBlank(traceId)) { template.header(X-Trace-Id, traceId); } // 传递认证token ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes ! null) { HttpServletRequest request attributes.getRequest(); String token request.getHeader(Authorization); if (StringUtils.isNotBlank(token)) { template.header(Authorization, token); } } } }这个拦截器会自动作用于所有 Feign 请求确保必要的上下文信息在服务间无损传递。这对于后续的链路追踪和权限校验至关重要。3. 分布式事务的抉择为什么最终选了 Seata AT 模式服务拆分了一个业务操作横跨多个数据库事务就成了大问题。经典的“发帖扣积分”场景发帖服务在内容数据库插入一条记录同时需要调用积分服务在积分数据库里扣除相应积分。如何保证这两个操作要么都成功要么都回滚3.1 常见方案的对比与权衡在引入 Seata 之前我们评估过几种方案本地消息表在发起事务的业务中同时往本地数据库插入一条业务记录和一条消息记录利用本地事务保证一致性。然后通过定时任务扫描消息表发送消息给下游服务。下游消费成功则回调确认。这种方式可靠性高但实现复杂需要建表、开发消息状态机、重试机制等对业务侵入也不小。消息队列MQ的最终一致性比如发帖成功后发送一条 MQ 消息积分服务监听并消费。这要求发帖操作本身是幂等的并且消息不能丢失。RocketMQ 的事务消息可以较好地支持但同样有编码复杂度且强依赖于 MQ 的可靠性。TCC 模式Try、Confirm、Cancel。需要业务方编写三个阶段的逻辑补偿逻辑复杂开发成本很高适用于对一致性要求极高且性能敏感的场景。Seata AT 模式自动补偿型事务。对代码侵入小只需在分布式事务的入口方法上加一个GlobalTransactional注解Seata 框架会自动拦截 SQL生成前后镜像实现回滚。看起来是最简单的。对于我们这个“仿小红书”项目核心诉求是开发效率高、对现有代码改造小、能覆盖大部分跨库写操作场景。Seata AT 模式几乎是为这种场景量身定制的。它不用我们写反向补偿逻辑不用操心消息的可靠投递框架层自动搞定。虽然其性能有一定损耗因为要解析SQL、记录undo_log并且对数据库操作有部分限制如不支持跨库的关联查询更新但在我们当前业务规模和复杂度下是完全可接受的。3.2 Seata 1.4.2 的核心组件与部署Seata 有三个核心角色TC (Transaction Coordinator): 事务协调器。独立部署的服务负责维护全局事务的状态驱动全局事务的提交或回滚。它是大脑。TM (Transaction Manager): 事务管理器。嵌入在应用中的组件负责开启、提交或回滚一个全局事务。通常就是加了GlobalTransactional注解的那个方法所在的服务。RM (Resource Manager): 资源管理器。也嵌入在应用中负责管理分支事务即每个微服务自己的本地事务的资源向 TC 注册分支事务并执行 TC 发出的提交或回滚指令。我们的部署架构是使用 Docker Compose 在开发环境一键部署一个 Seata-ServerTC生产环境则部署高可用集群。各个微服务应用TM和RM通过配置连接到这个 TC。# docker-compose-seata.yml version: 3.8 services: seata-server: image: seataio/seata-server:1.4.2 container_name: seata-server ports: - 8091:8091 # TC 控制台端口 - 7091:7091 # TC RPC 端口 environment: - SEATA_CONFIG_NAMEfile:/root/seata-config/registry - STORE_MODEdb # 事务日志存储模式这里用数据库 volumes: - ./seata/config:/root/seata-config - ./seata/logs:/root/logs networks: - microservice-net networks: microservice-net: external: true关键点在于STORE_MODE和配置文件。我们选择db模式将全局事务会话信息存储在 MySQL 中而不是默认的file模式这样更适合生产环境。这就需要我们提前准备好数据库并执行 Seata 提供的global_table.sql、branch_table.sql、lock_table.sql来建表。3.3 Spring Boot 2.4 与 Nacos 的集成配置我们的微服务用的是 Spring Boot 2.4.x 和 Spring Cloud Alibaba 2021.0.1.0。集成 Seata 客户端需要以下步骤引入依赖在需要参与分布式事务的服务中通常是涉及写操作的服务引入spring-cloud-starter-alibaba-seata。dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId version2021.0.1.0/version !-- 版本与你的Spring Cloud Alibaba保持一致 -- /dependency配置 Seata在application.yml中配置 Seata 客户端。重点是tx-service-group和 TC 的地址。spring: cloud: alibaba: seata: tx-service-group: my_test_tx_group # 事务组名称需与seata-server配置对应 seata: enabled: true application-id: ${spring.application.name} # 应用ID一般用服务名 tx-service-group: ${spring.cloud.alibaba.seata.tx-service-group} registry: type: nacos # 注册中心类型 nacos: server-addr: ${spring.cloud.nacos.discovery.server-addr} namespace: ${spring.cloud.nacos.discovery.namespace} group: SEATA_GROUP # Seata Server在Nacos中的分组需与server配置一致 config: type: nacos # 配置中心类型 nacos: server-addr: ${spring.cloud.nacos.config.server-addr} namespace: ${spring.cloud.nacos.discovery.namespace} group: SEATA_GROUP >数据源代理这是 Seata AT 模式能自动回滚的关键。它需要代理你的数据源以拦截和解析 SQL。如果你用的是 Spring Boot 默认的DataSource自动配置并且引入了seata-spring-boot-starter那么代理通常是自动完成的。但如果你用了多数据源比如 dynamic-datasource就需要特别注意。4. 多数据源与 Seata 集成的深水区我们的内容服务因为历史原因使用了读写分离所以引入了dynamic-datasource-spring-boot-starter来管理多个数据源。这在与 Seata 集成时带来了最大的挑战。4.1 dynamic-datasource 的版本适配与数据源代理首先版本匹配是关键。我们项目是 Spring Boot 2.4.x对应的 Spring 版本是 5.3.x。dynamic-datasource的 3.5.x 版本是为 Spring Boot 2.5 和 Spring 5.3 设计的而 3.4.x 版本更兼容 Spring Boot 2.4。经过测试我们选择了com.baomidou:dynamic-datasource-spring-boot-starter:3.4.1。单纯的引入 Seata 和 dynamic-datasource 依赖Seata 是无法正确代理到 dynamic-datasource 管理的数据源的。因为 Seata 默认代理的是 Spring 容器中名为dataSource的 Bean而 dynamic-datasource 创建的是一个DynamicRoutingDataSource。解决方案是我们需要手动配置将 dynamic-datasource 创建的数据源用 Seata 的DataSourceProxy包装一层然后再放回容器。Configuration public class DataSourceConfiguration { Primary Bean(dataSource) public DataSource dataSource(DataSourceProperties properties) { // 1. 这里假设你通过其他方式如ConfigurationProperties已经构建了Hikari数据源 HikariDataSource dataSource properties.initializeDataSourceBuilder() .type(HikariDataSource.class) .build(); // 2. 用Seata的DataSourceProxy进行包装 return new DataSourceProxy(dataSource); } // 如果你的dynamic-datasource配置是通过DS注解动态切换的那么你需要代理的是DynamicRoutingDataSource // 但更常见的做法是在配置每个具体数据源时就用DataSourceProxy包装 Bean(masterDataSource) ConfigurationProperties(prefix spring.datasource.master) public DataSource masterDataSource() { HikariDataSource ds new HikariDataSource(); // ... 设置连接池参数 return new DataSourceProxy(ds); // 关键包装 } Bean(slaveDataSource) ConfigurationProperties(prefix spring.datasource.slave) public DataSource slaveDataSource() { HikariDataSource ds new HikariDataSource(); // ... 设置连接池参数 return new DataSourceProxy(ds); // 关键包装 } Bean DependsOn({masterDataSource, slaveDataSource}) // 确保具体数据源先初始化 public DynamicRoutingDataSource dynamicDataSource( Qualifier(masterDataSource) DataSource masterDataSource, Qualifier(slaveDataSource) DataSource slaveDataSource) { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(master, masterDataSource); targetDataSources.put(slave, slaveDataSource); DynamicRoutingDataSource dataSource new DynamicRoutingDataSource(); dataSource.setDefaultTargetDataSource(masterDataSource); dataSource.setTargetDataSources(targetDataSources); return dataSource; } }这样配置后DS(“slave”)注解切换到的读库数据源也是被 Seata 代理过的。但这里要特别注意Seata 的 AT 模式只对写操作INSERT, UPDATE, DELETE生成 undo_log。对于纯读操作SELECT即使数据源被代理也没有任何额外开销所以可以放心用于读库。4.2GlobalTransactional注解的精准使用在集成了 Seata 并配置好多数据源代理后使用起来就简单了。在分布式事务的入口方法上加上GlobalTransactional注解即可。Service public class PostServiceImpl implements PostService { Autowired private PostMapper postMapper; Autowired private PointServiceClient pointServiceClient; // Feign客户端 Override GlobalTransactional(name “createPost”, rollbackFor Exception.class, timeoutMills 60000) public ResultLong createPost(CreatePostRequest request) { // 1. 本地事务插入帖子 Post post convertToPost(request); postMapper.insert(post); // 2. 远程调用扣除发帖积分 DeductPointRequest deductRequest new DeductPointRequest(); deductRequest.setUserId(request.getUserId()); deductRequest.setPoints(10); deductRequest.setBizType(“CREATE_POST”); ResultVoid deductResult pointServiceClient.deduct(deductRequest); if (!deductResult.isSuccess()) { // 这里抛出的异常会触发全局回滚 throw new BusinessException(“扣减积分失败: ” deductResult.getMessage()); } // 3. 可能还有其他本地或远程操作... // ... return Result.success(post.getId()); } }关键点解析rollbackFor Exception.class这是默认值意味着发生任何Exception都会触发回滚。如果你希望某些检查异常不触发回滚需要单独指定。timeoutMills 60000设置全局事务的超时时间单位毫秒。如果超过这个时间事务还未完成所有分支未完成提交或回滚TC 会主动触发回滚。这个时间要设置得比所有分支事务可能的最长处理时间之和还要长一些。异常传播全局事务的回滚依赖于异常。必须在事务边界内即GlobalTransactional注解的方法内当需要回滚时抛出异常。Feign 调用失败会抛FeignException业务失败我们抛自定义的BusinessException这些都会触发回滚。不要在异步方法中使用GlobalTransactional的原理是基于 Spring 的Transactional它通过线程上下文传递事务上下文。如果你在方法内启用了新线程或者用了Async事务上下文会丢失导致分布式事务失效。5. 实战排坑那些官方文档没细说的坑理论配置都通了一跑起来全是坑。下面是我在整合过程中遇到的几个典型问题及其解决方案。5.1 表结构必须包含主键Seata AT 模式在回滚时需要根据 SQL 执行前的前镜像before image和主键来定位要回滚的数据行。如果你的表没有主键Seata 就无法生成正确的回滚 SQL。在项目初期设计表时务必为每张表都加上主键最好是单列自增主键或业务无关的雪花算法ID。对于已有的无主键表需要先进行表结构改造。5.2 Undo_Log 表冲突与序列化问题每个参与分布式事务的数据库都需要创建 Seata 的undo_log表。这张表记录了数据修改的前后镜像。常见问题有两个表已存在错误在应用启动时如果 Seata 配置了client.undo.logTableundo_log默认并且数据源代理成功Seata 会尝试在第一次操作时自动创建这张表。但如果你的数据库用户没有 CREATE TABLE 权限或者表名冲突就会报错。稳妥的做法是在项目上线前手动在每个业务库中执行建表 SQL。序列化异常Undo log 中的镜像数据默认使用 Java 序列化serializer “jackson”需要额外配置。这意味着你的实体类必须实现Serializable接口。否则在记录 undo log 时会抛序列化异常。这是一个很容易被忽略的点记得给你所有可能被 Seata 代理操作的实体类加上implements Serializable。5.3 全局事务IDXID的传递与丢失XID 是 Seata 全局事务的唯一标识它需要在调用链中传递。Seata 通过拦截器自动在 HTTP 请求头中注入和提取txXid。但在以下几种情况下XID 可能会丢失导致分支事务无法关联到全局事务Feign 拦截器覆盖了Header如果你自定义了 Feign 的RequestInterceptor并且使用了template.header(key, value)方法它会覆盖已有的 header。正确做法是使用template.header(key, Collections.singletonList(value))或者先检查是否已存在。异步调用在新线程中进行的数据库操作或远程调用无法获取到父线程的 XID。对于必须异步的场景需要手动将 XID 传递过去RootContext.bind(xid)。非Spring托管的线程池比如你使用ExecutorService自己创建的线程池Spring 和 Seata 的上下文无法传递。建议使用 Spring 的ThreadPoolTaskExecutor或Async配合自定义的TaskExecutor以支持上下文传递。5.4 连接池与 Seata 数据源代理的顺序这是一个非常隐蔽的坑。如果你的应用同时使用了连接池如 HikariCP和 Seata那么 Bean 的初始化顺序必须是原始 DataSource - DataSourceProxy - 连接池包装。错误的顺序比如先被连接池包装再被 Seata 代理会导致 Seata 无法正确拦截到 SQL。在我们上面的配置示例中我们是先构建HikariDataSource然后用DataSourceProxy包装它最后这个代理数据源被 Spring 管理连接池属性HikariConfig是在构建HikariDataSource时设置的这个顺序是正确的。如果你在application.yml中通过spring.datasource.hikari.*配置连接池Spring Boot 会自动创建一个HikariDataSourceBean。此时你需要通过PostConstruct或BeanPostProcessor来确保这个 Bean 被DataSourceProxy替换。更推荐我们前面手动配置的方式清晰可控。6. 性能考量与监控闭环引入 Seata 带来了数据一致性的保障但也带来了额外的性能开销和复杂度。我们不能只求能用还得知道用起来代价如何以及出了问题怎么查。6.1 AT 模式下的性能损耗分析Seata AT 模式的性能损耗主要来自三个方面SQL 解析Seata 需要拦截所有写操作的 SQL进行解析以生成前后镜像。这部分会有一定的 CPU 开销。Undo Log 的写入每次写操作都需要在同库同事务中插入一条undo_log记录。这增加了一次数据库 IO。全局事务协调TM 与 TC、RM 与 TC 之间需要多次 RPC 通信开启、注册分支、上报状态、提交/回滚。优化建议缩小事务边界GlobalTransactional注解的范围要尽可能小只包含必须在一个事务内的操作。避免在全局事务内进行长时间的计算或无关的 IO 操作。避免长事务设置合理的timeoutMills并及时处理异常避免事务悬挂。TC 集群与高可用生产环境一定要部署 Seata-Server 集群并使用数据库或 Redis 作为存储模式避免单点故障。关注undo_log表这张表会随着写操作增长需要定期清理已提交事务的日志Seata 有内置的异步任务但也要监控表大小。6.2 搭建可观测性日志、链路与监控分布式事务出了问题排查起来比单体事务困难得多。必须建立完善的可观测性体系。日志整合确保每个微服务的日志都输出全局事务 ID (XID) 和分支事务 ID (Branch ID)。这可以通过配置 Logback 或 Log4j2 的 MDC 实现。在日志格式中加入%X{traceId}|%X{xid}这样在排查问题时可以通过 XID 把所有相关服务的日志串联起来。Seata 控制台Seata-Server 自带一个控制台可以查看全局事务会话、分支事务的详细状态、锁信息等。在开发测试环境这是排查事务问题的利器。生产环境可以考虑将其部署在内网供运维人员使用。链路追踪集成将 XID 注入到你的链路追踪系统如 SkyWalking, Zipkin中。通常可以在 Tracing 的 Baggage 或 Tag 里加上 XID。这样在链路追踪的视图中你不仅能看到服务调用关系还能看到这次调用属于哪个分布式事务一目了然。业务状态监控对于核心的分布式事务场景可以增加业务层的监控。例如在“发帖扣积分”场景可以记录一个日志事务开始、本地帖子创建成功、远程扣积分调用成功、全局事务提交。通过监控这些日志的成功率可以快速发现是哪个环节出了问题。微服务架构改造特别是分布式事务的引入是一个从“能用”到“好用且可靠”的跨越。Feign 解决了服务间如何通信的问题而 Seata 则试图解决通信后数据如何保持一致的问题。没有银弹Seata AT 模式用一定的复杂性和性能损耗换来了开发效率的提升和大部分场景下的数据安全。在项目中期这是一个性价比很高的选择。但也要清醒地认识到它的边界对于超高并发或对性能极度敏感的场景可能还需要结合消息队列最终一致性、TCC等模式做更精细的设计。我们的“仿小红书”项目走到这一步算是把微服务的骨架真正搭稳了接下来就是往里面填充更复杂的业务血肉比如推荐流、搜索、实时消息等等那又是另一片广阔的天地了。
返回列表