ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ForgeAdmin分布式幂等组件v2.0实战:高并发下防重复请求架构设计

ForgeAdmin分布式幂等组件v2.0实战:高并发下防重复请求架构设计 1. 项目背景与核心价值最近在重构我们团队的一个核心业务系统遇到了一个老生常谈但又极其棘手的问题分布式环境下的重复请求。事情是这样的我们有一个订单创建接口高峰期用户可能因为网络抖动、前端防重失效或者单纯的“手快”而连续点击提交按钮。在单体应用时代我们可能加个本地锁或者用数据库唯一索引就解决了。但现在系统已经拆成了十几个微服务订单服务调用库存服务、优惠券服务链路一长任何一个环节的重复调用都可能导致库存多扣、优惠券多发最终造成资损。我们之前用的是一套自研的简易幂等组件基于Redis实现但在高并发场景下暴露了不少问题比如锁竞争激烈、异常情况下的数据一致性难以保证。正是在这个背景下我们决定引入并深度改造ForgeAdmin开源项目中的分布式幂等组件并将其从v1.x版本升级到v2.0。ForgeAdmin本身是一个快速开发平台其幂等组件设计理念清晰但原版更偏向于通用场景。我们这次升级不仅仅是版本号的变更更是结合自身业务高并发、高可用的要求进行了一次从架构设计到实现细节的“外科手术式”重构。这次实战的目标很明确打造一个能扛住我们业务流量洪峰同时保证绝对数据一致性的分布式幂等防线。简单来说这次升级解决的核心痛点就三个第一在高并发锁竞争下如何让请求“排好队”避免系统被拖垮第二在分布式事务的复杂场景中比如涉及订单和库存的联动如何确保幂等逻辑与业务事务的强一致性第三如何设计一个具备高可用和容错能力的组件即使Redis出现短暂抖动业务也不能大面积失败。如果你也在为分布式系统中的重复请求而头疼或者正在评估各类幂等方案那么我们在ForgeAdmin v2.0升级过程中趟过的路、踩过的坑或许能给你带来一些实实在在的参考。2. 架构设计思路与方案选型2.1 从v1.x到v2.0问题驱动下的设计演进ForgeAdmin v1.x版本的幂等组件其核心思路是经典的“Token验证”模式。大致流程是客户端先请求一个幂等令牌Token服务端将其存入Redis并设置一个较短的过期时间客户端携带此Token发起业务请求服务端收到请求后尝试用Redis的setnx命令去抢占这个Token对应的键抢到则执行业务抢不到则认为是重复请求。这个方案简单有效对于一般并发场景够用。但在我们的压测中它很快遇到了瓶颈。首先是锁竞争热点问题。所有针对同一业务键比如同一个订单号的请求都会去争抢Redis中的同一个键。在秒杀场景下这相当于把压力全部转移到了Redis的一个节点上setnx的争用会导致大量请求阻塞RT响应时间飙升甚至拖慢Redis本身。v1.x版本没有做任何排队或串行化处理纯粹依赖Redis的原子操作在高并发下显得力不从心。其次是与业务事务的隔离性问题。v1.x版本中“标记请求已处理”即删除或修改Redis中的Token状态这个动作通常是在业务代码执行完成后进行的。这就存在一个时间窗口业务事务可能已提交但标记动作还未完成。如果此时服务恰好重启或发生异常这个Token可能因为未被成功标记而依然存在于Redis中导致后续合法的重试请求被误判为重复请求而拒绝。这违背了幂等性“一次与多次请求具有相同副作用”的本质变成了“可能连一次都执行不了”。基于这些问题v2.0的设计目标聚焦在三点降低锁竞争引入更细粒度的锁机制和排队逻辑将无序竞争变为有序执行。保证最终一致性将幂等标记与业务事务绑定确保二者同时成功或失败。提升容错性设计降级策略和本地缓存避免因Redis单点故障导致服务不可用。2.2 核心方案令牌桶状态机本地缓存我们最终确定的v2.0架构可以概括为“一令牌、两阶段、三状态、四缓存”。一令牌Token保留了Token机制但赋予了它更多内涵。Token不再只是一个简单的字符串键而是一个包含业务唯一键如order:123、请求指纹由业务参数生成摘要、以及初始状态INIT的复合对象。Token的生成增加了客户端IP、时间戳等因子进一步降低碰撞概率。两阶段处理这是解决事务一致性的关键。我们将幂等检查拆分为两个阶段第一阶段Try在业务事务开始之前进行Token的预占和状态检查。此时并不立即执行业务而是将Token状态从INIT置为PROCESSING表示请求已进入处理流程。这个操作本身是轻量级的。第二阶段Confirm/Cancel在业务事务提交之后根据事务结果将Token状态最终置为SUCCESS或FAILED。我们将这个“确认”动作与数据库事务的提交放在同一个本地事务中通过TransactionSynchronizationManager注册回调利用“本地事务表”或“事务消息”的思想确保二者强一致。如果业务事务回滚则自动将Token状态回滚为FAILED允许客户端重试。三状态机我们为Token定义了明确的状态流转形成一个状态机INIT-PROCESSING- (SUCCESS/FAILED)。任何请求到来都先检查当前状态。如果是PROCESSING则说明有请求正在处理后续请求需要等待或快速失败根据策略如果是SUCCESS则直接返回上次的结果如果是FAILED则允许重试。状态机使得幂等逻辑非常清晰也便于监控和排查问题。四层缓存为了应对Redis压力和实现容灾我们设计了多层缓存的降级策略本地ThreadLocal缓存在一次请求线程内如果已通过幂等校验则结果缓存在ThreadLocal中避免同线程内重复访问Redis。本地Caffeine缓存在应用实例内存中缓存最近处理成功的Token结果SUCCESS状态及其业务结果。设置合理的容量和过期时间可以拦截绝大部分重复请求极大减轻Redis压力。Redis分布式缓存作为全局状态存储的核心存储所有Token的状态和结果。我们使用Redis Hash结构存储Token对象便于原子化地更新状态字段。数据库持久化层可选对于极其核心的业务可以将最终的幂等记录SUCCESS状态异步落库作为审计和最终核对依据即使Redis数据丢失也能从数据库恢复状态。这个架构的核心思想是通过状态机保证逻辑正确性通过两阶段提交保证事务一致性通过多层缓存和排队策略保证高性能和高可用。3. 核心实现细节与关键技术点3.1 幂等令牌Token的设计与生成Token是整个组件的基石设计上必须保证全局唯一性和业务相关性。我们不再使用简单的UUID。public class IdempotentToken { // 业务唯一键如 “order:create:{userId}:{productId}” private String businessKey; // 请求指纹由业务关键参数通过MD5等摘要算法生成如 “MD5(orderId123amount100)” private String requestFingerprint; // 令牌状态INIT, PROCESSING, SUCCESS, FAILED private String status; // 业务执行结果JSON序列化后存储仅在SUCCESS状态时有效 private String result; // 创建时间、过期时间 private Long createTime; private Long expireTime; // 客户端标识IP、应用名等用于监控和排查 private String clientInfo; }生成策略businessKey需要业务方根据场景精心设计原则是能唯一标识一个业务操作。例如支付幂等键可以是pay:order:{orderId}而创建订单的幂等键可能需要包含用户和商品信息order:create:{userId}:{skuId}防止同一用户对同一商品重复创建订单。requestFingerprint则用于在businessKey相同的情况下比如同一订单的多次支付请求进一步区分请求参数是否完全相同如果指纹不同即使businessKey相同也可能需要重新处理例如支付金额变了。存储设计在Redis中我们使用Hash结构存储这个Token对象Key就是businessKey。这样可以原子性地更新单个字段如状态也方便一次性获取全部信息。我们为这个Key设置了合理的TTL例如24小时避免无效数据长期堆积。3.2 基于Redis Lua脚本的原子化状态操作状态机的流转必须是原子的不能出现“读-改-写”竞态条件。我们放弃了在Java代码里使用jedis.get()再jedis.set()的方式而是将所有状态判断和修改逻辑封装在Redis Lua脚本中执行。例如tryAcquire尝试获取处理权的Lua脚本核心逻辑如下local key KEYS[1] -- 业务Key local newStatus ARGV[1] -- 目标状态PROCESSING local currentToken redis.call(HGETALL, key) if not currentToken or #currentToken 0 then -- 令牌不存在初始化一个并设置为处理中 redis.call(HMSET, key, status, newStatus, createTime, ARGV[2]) redis.call(EXPIRE, key, ARGV[3]) return ACQUIRED -- 获取成功 else local existingStatus currentToken[status] if existingStatus INIT or existingStatus FAILED then -- 初始或失败状态可以抢占 redis.call(HSET, key, status, newStatus) return ACQUIRED elseif existingStatus PROCESSING then -- 正在处理获取处理开始时间判断是否超时 local processStartTime tonumber(currentToken[processTime] or 0) local now tonumber(ARGV[2]) if (now - processStartTime) tonumber(ARGV[4]) then -- 处理超时强制抢占并更新处理时间需谨慎可能涉及业务补偿 redis.call(HSET, key, status, newStatus, processTime, now) return ACQUIRED_FORCE else return PROCESSING -- 告知调用方正在处理中 end elseif existingStatus SUCCESS then -- 已成功直接返回缓存的结果 return {SUCCESS, currentToken[result]} end end这个脚本在一次Redis通信中完成了状态判断、超时处理、状态更新和结果返回所有操作保证了原子性。ARGV参数传入时间戳、超时阈值等使得逻辑可配置。3.3 分布式锁竞争优化从无序争抢到有序队列对于同一个businessKey的高并发请求即使有了原子化的状态判断大量请求同时执行Lua脚本对Redis本身也是压力。我们引入了“本地排队”机制来缓解。思路是在应用实例层面为每个businessKey维护一个本地的并发队列可以使用ConcurrentHashMapReentrantLock或Semaphore。当请求到来时首先尝试获取这个businessKey对应的本地锁ReentrantLock.tryLock(shortWaitTime)。如果获取成功该线程获得本地执行权再去执行上述Redis Lua脚本。如果获取失败说明本地已有其他线程正在处理相同key的请求则当前线程不是立即失败而是尝试入队等待一个更短的时间例如50ms或者根据策略直接返回“处理中”提示。这样做的好处是对于同一个业务键的并发请求在应用层就被序列化了最终只有一个线程会去访问Redis执行状态转换极大地减少了Redis的无效竞争。这本质上是将分布式锁的部分压力消化在了应用内存中。注意本地排队机制需要仔细设计等待时间和队列长度避免线程堆积。同时在应用多实例部署时这个方案只能缓解单个实例内的竞争实例间的竞争仍需通过Redis协调。但对于很多业务场景用户请求通过网关层负载均衡后短时间内同一用户的请求落到同一服务实例的概率较高因此本地排队效果显著。3.4 与Spring事务的集成确保最终一致性这是v2.0升级中最关键也最复杂的一环。我们必须保证“将Token状态标记为SUCCESS”这个动作与“数据库业务事务提交”这个动作要么都成功要么都失败。我们利用Spring的TransactionSynchronization接口来实现。在业务方法执行前Around切面我们完成Token的预占状态置为PROCESSING并将业务执行逻辑包装起来。在业务方法执行后、事务提交前我们注册一个同步回调Component public class IdempotentTransactionManager { Autowired private RedisIdempotentService idempotentService; public void confirmTokenAfterCommit(String businessKey, Object result) { // 注册事务同步回调 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { // 事务提交成功后确认Token状态 idempotentService.confirmToken(businessKey, result); } Override public void afterCompletion(int status) { if (status ! STATUS_COMMITTED) { // 事务回滚后将Token状态置为FAILED允许重试 idempotentService.cancelToken(businessKey); } } }); } }在业务切面中伪代码如下Around(annotation(idempotent)) public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) { String businessKey generateKey(joinPoint); // 1. 尝试获取Token处理权执行Lua脚本 AcquireResult acquireResult redisIdempotentService.tryAcquire(businessKey); if (acquireResult.isSuccess()) { // 2. 注册事务同步回调 idempotentTransactionManager.confirmTokenAfterCommit(businessKey, null); try { // 3. 执行业务方法 Object result joinPoint.proceed(); // 4. 将业务结果暂存在afterCommit回调中使用 TransactionContextHolder.setResult(businessKey, result); return result; } catch (Exception e) { // 业务异常事务会回滚afterCompletion会触发cancelToken throw e; } } else { // 获取处理权失败返回上次结果或抛出幂等异常 return handleAcquireFailure(acquireResult); } }这样confirmToken操作只会在数据库事务真正提交后执行。如果事务回滚cancelToken会被调用。这就实现了幂等状态与业务数据的强一致性。实操心得这里有个隐蔽的坑。如果confirmToken操作本身即写Redis失败了怎么办事务已经提交业务数据已落地但幂等状态没更新后续请求会被拒绝。为此我们的confirmToken方法必须具备重试机制。我们实现了一个简单的异步重试队列如果Redis设置失败会将这个确认任务丢到队列里由后台线程不断重试直到成功。同时在tryAcquire的Lua脚本中需要增加对“僵尸PROCESSING状态”的处理即处理超时作为这种极端情况下的补偿手段允许新的请求强制接管。4. 升级实施与集成步骤4.1 环境准备与依赖引入首先确保你的项目环境。我们基于Spring Boot 2.7和JDK 11。在pom.xml中引入核心依赖。除了原有的Spring Boot Starter Data Redis我们还需要引入Caffeine作为本地缓存以及用于参数摘要的工具。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId version3.1.8/version /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-lang3/artifactId /dependency dependency groupIdorg.aspectj/groupId artifactIdaspectjweaver/artifactId /dependency配置Redis连接和Caffeine缓存。在application.yml中spring: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} lettuce: pool: max-active: 20 max-wait: -1ms max-idle: 10 min-idle: 5 forge: idempotent: enabled: true # Token默认过期时间建议大于业务最大处理时间 default-expire-time: 3600s # 本地缓存配置 local-cache: maximum-size: 10000 expire-after-write: 300s # 处理中超时时间超过此时间PROCESSING状态可被强制接管 process-timeout: 30s4.2 核心组件配置与Bean定义接下来定义配置类初始化核心Bean。Configuration EnableConfigurationProperties(IdempotentProperties.class) public class IdempotentAutoConfiguration { Bean ConditionalOnMissingBean public RedisTemplateString, Object idempotentRedisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // 使用String序列化器方便阅读Lua脚本操作需对应 StringRedisSerializer stringSerializer new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet(); return template; } Bean public CacheString, IdempotentResult localIdempotentCache(IdempotentProperties properties) { return Caffeine.newBuilder() .maximumSize(properties.getLocalCache().getMaximumSize()) .expireAfterWrite(properties.getLocalCache().getExpireAfterWrite()) .recordStats() // 记录缓存统计信息便于监控 .build(); } Bean public IdempotentKeyGenerator idempotentKeyGenerator() { return new DefaultIdempotentKeyGenerator(); // 默认实现基于方法签名和参数 } Bean public IdempotentAspect idempotentAspect(RedisIdempotentService idempotentService) { return new IdempotentAspect(idempotentService); } Bean public RedisIdempotentService redisIdempotentService(RedisTemplateString, Object redisTemplate, CacheString, IdempotentResult localCache, IdempotentProperties properties) { return new RedisIdempotentServiceImpl(redisTemplate, localCache, properties); } }4.3 业务代码接入与注解使用对于业务开发人员来说接入变得非常简单。只需要在需要幂等保护的方法上添加Idempotent注解并指定幂等键的生成规则SpEL表达式。Service public class OrderServiceImpl implements OrderService { Override Idempotent( key order:create: #userId : #orderRequest.productSkuId, expireTime 1800, message 正在创建订单请勿重复提交 ) public OrderCreateResult createOrder(Long userId, OrderCreateRequest orderRequest) { // 1. 参数校验 // 2. 业务逻辑扣减库存、生成订单号、保存订单等 // 3. 返回结果 OrderCreateResult result new OrderCreateResult(); result.setOrderId(generateOrderId()); // 注意方法的返回值会被自动缓存用于后续重复请求的返回 return result; } Override Idempotent(key order:pay: #payRequest.orderId) public PayResult payOrder(PayRequest payRequest) { // 支付逻辑 // 这里演示了另一种key生成方式直接使用请求体内的订单ID return executePayment(payRequest); } }Idempotent注解的主要属性key必填SpEL表达式用于生成唯一的businessKey。这是幂等控制的核心必须确保同一业务操作的key相同。expireTime可选Token在Redis中的过期时间秒不设置则使用全局默认值。message可选当请求被识别为重复请求时返回给客户端的提示信息。关键点key的设计至关重要。它必须包含能唯一标识“一次业务操作”的所有要素。例如创建订单如果只用用户ID那么用户就无法并发创建多个订单如果加上商品SKU ID就能防止对同一商品重复下单如果再加上时间戳或随机数可能就过于宽松起不到防重作用。需要根据具体业务语义来权衡。4.4 灰度发布与监控埋点在全面升级v2.0组件前我们进行了灰度发布。通过配置中心动态控制Idempotent注解的生效开关先对少量非核心业务或流量较低的服务开启观察日志和监控指标。我们为组件添加了丰富的监控埋点使用Micrometer将指标暴露给Prometheus性能指标idempotent_request_total幂等拦截请求总量。idempotent_request_duration_seconds幂等处理耗时分布。idempotent_cache_hits_total本地缓存命中次数。idempotent_redis_commands_total各类Redis命令如EVAL执行Lua脚本调用次数。业务指标idempotent_status_count按状态ACQUIRED, PROCESSING, SUCCESS_HIT, CONFLICT等统计的请求数。idempotent_force_acquire_total强制接管超时处理中请求的次数可能预示异常。健康检查将Redis连接和Lua脚本加载情况纳入服务健康检查端点如/actuator/health确保组件依赖的基础设施是健康的。通过监控大盘我们可以清晰地看到新组件上线后Redis的QPS是否下降接口平均响应时间是否改善以及各种状态请求的比例是否正常。5. 压测对比、问题排查与优化实录5.1 压测数据对比v1.x vs v2.0我们使用JMeter对同一个“创建订单”接口进行了压测模拟1000个用户对100个不同的商品即100个不同的businessKey进行秒杀式请求持续5分钟。指标v1.x 版本v2.0 版本提升/变化平均响应时间 (RT)约 450ms约 120ms下降73%P99响应时间约 2.1s约 350ms下降83%Redis QPS峰值 8500峰值 2200下降74%接口吞吐量 (TPS)约 1800约 4200提升133%业务成功率99.2%99.99%更加稳定错误类型大量RedisTimeoutException、偶发DuplicateRequestException几乎无Redis超时重复请求被快速返回缓存结果系统更稳定分析v2.0版本通过本地缓存拦截了绝大部分重复请求通过本地排队大幅减少了Redis的无效竞争使得Redis压力骤降接口RT和吞吐量得到显著改善。P99的大幅降低尤其说明v2.0有效避免了少数请求因锁竞争而长时间等待的问题。5.2 典型问题排查实录在升级和压测过程中我们遇到了几个典型问题问题一本地缓存与Redis数据不一致现象个别请求在A实例处理成功但B实例的本地缓存未更新后续相同请求打到B实例时本地缓存未命中又去请求Redis虽然Redis返回了SUCCESS和结果但产生了额外的网络开销。排查检查Caffeine缓存配置发现expireAfterWrite设置过短60秒而业务处理可能超过60秒导致缓存失效。同时多实例间缓存天然不一致。解决适当延长本地缓存过期时间使其略大于业务最大处理时间。明确本地缓存的定位是“性能加速器”而非“唯一真相源”。我们调整了代码逻辑即使本地缓存命中也仅返回结果本地缓存未命中时查询Redis拿到结果后除了返回还会异步刷新本地缓存采用Cache.put允许覆盖。这样保证了最终一致性且对业务透明。对于极端一致性要求的场景提供了注解参数localCache false来关闭本地缓存。问题二事务回滚后Token状态未能及时置为FAILED现象在集成测试中模拟业务逻辑抛出异常导致事务回滚但偶尔发现Token状态仍为PROCESSING导致重试请求被阻塞。排查发现是TransactionSynchronization.afterCompletion方法在某些非常规的事务传播行为如REQUIRES_NEW或事务管理器配置下回调顺序或执行可能出现问题。解决增加了更健壮的状态补偿机制。在tryAcquire的Lua脚本中加强了对PROCESSING状态的超时判断process-timeout。如果一个请求处于PROCESSING状态超过设定时间如30秒则允许新的请求强制将其状态置为FAILED并接管。增加了后台定时任务扫描Redis中长时间处于PROCESSING状态的Token将其自动置为FAILED并记录告警日志方便人工介入排查根本原因。问题三SpEL表达式生成的Key冲突现象两个不同的业务方法因参数巧合生成了相同的businessKey导致A方法的幂等影响了B方法。排查发现开发人员在注解中写的key过于简单例如Idempotent(key “‘order:’ #id”)而两个方法参数里都有id。解决制定Key命名规范强制要求key必须包含类名、方法名作为前缀。例如‘idem:’ targetClass.simpleName ‘:’ methodName ‘:’ …。在DefaultIdempotentKeyGenerator中提供默认实现自动拼接类名和方法名业务只需补充业务参数部分。在代码评审阶段将Idempotent注解的key设计作为重点检查项。5.3 性能优化点总结Lua脚本优化将多个Redis操作压缩到一个脚本中是减少网络往返、保证原子性的不二法门。务必确保脚本逻辑严谨处理好所有边界条件。本地缓存策略Caffeine缓存的maximumSize和expireAfterWrite需要根据业务规模和内存情况仔细调优。监控缓存命中率是重要的调优依据。连接池配置高并发下Redis连接池参数max-active,max-idle,max-wait对性能影响巨大。需要结合压测结果调整避免连接不足导致的等待或连接过多造成的资源浪费。序列化选择使用StringRedisSerializer序列化Key配合GenericJackson2JsonRedisSerializer序列化Hash Value在可读性和性能之间取得平衡。如果极致追求性能可以考虑Kryo或Protostuff但会牺牲可调试性。降级开关在IdempotentProperties中配置一个全局开关和针对特定Key的前缀开关。在Redis出现严重故障时可以通过配置中心动态关闭幂等校验降级为直接处理业务保证核心流程可用同时记录日志以备后续核对。6. 总结与展望这次ForgeAdmin分布式幂等组件v2.0的升级实战对我们团队来说是一次深刻的基础架构洗礼。从最初被动的“救火”处理资损问题到主动的系统性重构我们不仅解决了一个具体的技术难题更沉淀了一套应对分布式共性问题的设计方法论即状态机定义清晰状态、原子操作保证并发安全、最终一致性对齐业务事务、多层防御保障系统韧性。目前这个组件已经稳定支撑了核心交易系统好几个大促周期。回头看有几个体会特别深第一设计阶段多花一分钟思考边界情况上线后就能省下十小时排查时间。状态机的每个流转、Lua脚本的每个判断都需要反复推敲。第二可观测性不是可选项而是必选项。没有详细的指标和日志我们根本无法快速定位到缓存不一致、事务回调异常这些问题。第三再好的技术方案也需要配上清晰的规范和宣导。我们编写了详细的组件使用手册并组织了分享会确保每位开发同学都理解Idempotent注解的key该如何设计避免误用。关于未来我们已经在规划v2.1版本的方向。一个是探索与分布式链路追踪如SkyWalking、Jaeger的集成将幂等Token的生命周期与TraceID关联实现从网关到最终服务调用链路的全链路幂等追踪让问题排查更加立体。另一个是考虑支持基于数据库如MySQL的幂等实现作为Redis之外的另一种可选存储方案适用于对Redis依赖有顾虑或数据持久化要求更高的场景。技术演进的路还很长但有了这次扎实的v2.0升级经验我们对走好接下来的路充满了信心。
返回列表