一、 问题概述与核心思路
在分布式系统中,由于网络抖动重试、前端重复提交或多点线程并发等原因,完全相同的请求可能会在短时间内多次到达服务端。幂等性设计的核心目标是:确保对于同一业务请求的多次调用,其对系统资源状态造成的影响,与仅调用一次完全一致。
实现的关键在于,为每个请求赋予一个全局唯一的业务标识(如订单号、支付流水号或幂等Token),并在处理流程中基于该标识进行判重和协调。
二、 分层防御策略与具体实现
以下防御策略可组合使用,构成纵深防御体系。
1. 网关层:快速过滤
网关层作为流量的第一入口,适合进行粗粒度的重复请求拦截。
- 实现原理:提取请求的“指纹”,通常由
IP + 请求路径 + 核心业务参数(如用户ID+订单ID)经过MD5或SHA生成。将此指纹作为Key,在Redis中设置一个短暂的过期时间(如1-5秒)。 - 代码示例:
// 伪代码示例:使用Spring Cloud Gateway的全局过滤器 public class DedupFilter implements GlobalFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String requestFingerprint = generateFingerprint(exchange.getRequest()); // 使用SETNX原子操作,只有第一个请求能设置成功 Boolean isAbsent = redisTemplate.opsForValue().setIfAbsent("GATEWAY_DEDUP:" + requestFingerprint, "", Duration.ofSeconds(2)); if (Boolean.TRUE.equals(isAbsent)) { return chain.filter(exchange); // 放行 } else { exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUERTS); return exchange.getResponse().setComplete(); // 拦截 } } } - 注意事项:此方法适用于对时间窗内完全相同的请求进行去重,无法处理如“增加积分”这类参数相同但业务逻辑不允许重复的请求。
2. 接口层:幂等Token与分布式锁
这是实现业务幂等性的核心层。
- a) 幂等Token机制:适用于需要明确防止重复提交的场景,如支付。
- 申请Token:客户端在执行业务操作前(如进入支付页面),先调用服务端接口获取一个全局唯一的Token。
- 提交请求:客户端携带此Token发起业务请求。
- 校验Token:服务端通过Redis的
SET key value NX(原子性命令)判断此Token是否存在。若存在(表示是第一次请求),则删除Token并执行业务;若不存在,则直接返回重复请求的结果。
- b) 分布式锁:适用于控制对同一关键资源的并发访问,例如防止同一用户同时发起多笔付款。
- 实现原理:以业务唯一标识(如
订单ID)为Key,使用Redis的SETNX或Redisson等框架尝试获取锁。必须为锁设置合理的过期时间,防止死锁。操作完成后释放锁,但要注意判断释放的是不是自己的锁。 - 代码示例(Lua脚本保证原子性):
-- 加锁Lua脚本 local key = KEYS[1] local requestId = ARGV[1] -- 唯一请求ID,用于安全释放 local expireTime = ARGV[2] if redis.call('setnx', key, requestId) == 1 then redis.call('expire', key, expireTime) return 1 -- 成功 else return 0 -- 失败 end
- 实现原理:以业务唯一标识(如
3. 业务层:状态机与乐观锁
这是保证业务逻辑正确性的最后防线。
- a) 状态机:适用于订单等有明确状态流转的业务。
- 实现原理:在更新状态时,不仅校验当前状态,更要在SQL的WHERE条件中指定前置状态。例如,将订单状态从“未支付”更新为“已支付”:
此操作天然幂等,因为第二次执行时,由于状态已不是UPDATE `order` SET status = 'PAID' WHERE order_id = '123' AND status = 'UNPAID';UNPAID,影响行数为0。
- 实现原理:在更新状态时,不仅校验当前状态,更要在SQL的WHERE条件中指定前置状态。例如,将订单状态从“未支付”更新为“已支付”:
- b) 乐观锁:适用于更新操作,防止并发下的数据覆盖。
- 实现原理:在数据表中增加一个
version版本号字段。更新时,携带版本号作为条件。
如果版本号不匹配,更新会失败,需要业务逻辑进行重试或报错。UPDATE account SET balance = balance - 100, version = version + 1 WHERE account_id = 'A' AND version = 1;
- 实现原理:在数据表中增加一个
4. 数据层:最终屏障
- 唯一索引:这是最直接、最有效防止数据重复的方法。例如,为订单表的
order_no字段建立唯一索引,可以从数据库层面杜绝重复订单的创建。 - 去重表:对于没有唯一业务ID的复杂场景,可以单独创建一张“去重表”,将需要去重的业务字段组合作为唯一索引。在处理请求前,先向该表插入一条记录,插入成功则继续后续业务,插入失败则说明是重复请求。
三、 支付场景幂等方案示例
结合以上策略,一个典型的支付接口幂等方案如下:
- 生成幂等Token:用户进入收银台时,前端调用后端接口生成一个全局唯一的
idempotentToken,并存入Redis(过期时间稍长,如30分钟)。 - 支付请求:前端发起支付,在请求头或参数中携带
idempotentToken和orderId。 - Token校验(接口层):使用Lua脚本原子性地校验并删除Token。
// 伪代码:使用RedisTemplate执行Lua脚本 String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) " + "else return 0 end"; Long result = redisTemplate.execute(script, Arrays.asList("PAY_TOKEN:" + orderId), idempotentToken); if (result == 0) { // Token不存在或已使用 throw new IdempotentException("请勿重复支付"); } - 获取分布式锁(业务层):以
orderId为Key获取分布式锁,防止针对同一订单的极高并发。 - 状态机校验(业务层):在事务中,执行状态机更新:
如果更新成功,才进行扣款等后续操作。UPDATE `order` SET status = 'PAID' WHERE order_id = #{orderId} AND status = 'UNPAID'; - 数据层保证:支付流水表通过
order_id的唯一索引做最终防护。
四、 消息队列重复消费的幂等处理
对于来自Kafka等消息队列的重复消息,其解决方案与HTTP请求类似:
- 核心:在消费者端实现幂等性。
- 方案:为每条消息生成一个全局唯一的
MessageId(或在生产端由业务方注入幂等Key)。消费者在处理消息前,先查询Redis或数据库,判断该MessageId是否已被处理过。
6359

被折叠的 条评论
为什么被折叠?



