本文目录导读:

- 核心思想:唯一标识 + 去重表/状态机
- 方案一:基于数据库唯一索引(最常用、最可靠)
- 方案二:基于 Redis 分布式锁 + Token(高并发、高性能)
- 方案三:基于状态机(防重复更新)
- 方案四:基于消息队列的幂等(去重表)
- 方案五:全局唯一ID + 幂等中间件(框架级别)
- 总结与选型建议
这是一个非常经典的分布式系统设计问题,所谓的幂等性,通俗来说就是同一个请求无论被调用多少次,对系统产生的影响(即最终状态)都是一次性的。
在分布式环境中,由于网络超时、重试、消息重复消费等原因,幂等性是防止数据重复、资金损失、库存超卖的核心保障。
以下是 Java 分布式幂等案例落地的五个核心方案,从简单到复杂,包含具体实现代码和场景分析。
核心思想:唯一标识 + 去重表/状态机
所有幂等方案的核心都是:在第一次请求时生成或使用一个全局唯一的Key,并在后续请求中检查这个Key是否已经被处理过。
基于数据库唯一索引(最常用、最可靠)
适用场景: 核心交易、订单创建、资金流水等对数据一致性要求极高的场景。
原理: 利用数据库表的 UNIQUE KEY 约束,第一次插入成功,第二次插入会抛出 DuplicateKeyException(或类似异常),此时捕获异常并认为是重复请求。
案例:防止重复下单
建表(MySQL)
CREATE TABLE `t_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '业务幂等键,如订单号', `user_id` bigint NOT NULL, `amount` decimal(10,2) NOT NULL, `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付', `version` int NOT NULL DEFAULT 0, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) -- 关键:唯一索引 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Java Service 实现
@Service
@Transactional // 必须开启事务,保证插入和业务操作原子性
public class OrderService {
@Autowired
private OrderMapper orderMapper;
public Order createOrder(String orderNo, Long userId, BigDecimal amount) {
// 1. 尝试插入幂等记录
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setAmount(amount);
order.setStatus(0);
try {
// 关键步骤:如果order_no重复,这里会抛出 DuplicateKeyException
orderMapper.insert(order);
} catch (DuplicateKeyException e) { // 或 DataIntegrityViolationException
// 2. 捕获唯一键冲突异常,表示重复请求
log.warn("重复的订单请求,orderNo: {}", orderNo);
// 查询已存在的订单并返回(幂等返回)
return orderMapper.selectByOrderNo(orderNo);
}
// 3. 第一次插入成功,继续执行业务逻辑(如扣库存、发消息)
// ...
return order;
}
}
优点: 强一致性,实现简单,业务与数据库交互天然支持。 缺点: 数据库写压力大;异常处理需要区分是业务异常还是幂等冲突(容易混)。
基于 Redis 分布式锁 + Token(高并发、高性能)
适用场景: 高并发、对响应速度要求高、可以容忍短暂的最终一致性(如秒杀抢购、优惠券发放)。
原理: 客户端在发起请求前,先向服务端请求一个 Token(唯一标识),服务端将 Token 存入 Redis 并设置过期时间,请求时携带 Token,服务端使用 Redis 的 setnx 命令(或 Lua 脚本)进行原子性检查并删除(或更新状态)。
案例:防止表单重复提交(前端+后端联动)
获取 Token 接口
@RestController
public class TokenController {
@Autowired
private StringRedisTemplate redisTemplate;
@GetMapping("/token")
public String getToken(@RequestParam String userId) {
String token = UUID.randomUUID().toString();
String redisKey = "idempotent:token:" + userId;
// 存入Redis,有效期30分钟
redisTemplate.opsForValue().set(redisKey, token, 30, TimeUnit.MINUTES);
return token;
}
}
业务接口(核心逻辑)
@Service
public class PaymentService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 使用Lua脚本保证原子性:检查Token是否存在 -> 删除Token -> 执行业务
*/
private static final String LUA_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
public boolean pay(String userId, String token, BigDecimal amount) {
String redisKey = "idempotent:token:" + userId;
// 1. 执行Lua脚本:如果token匹配且未被使用,则删除并返回1
Long result = redisTemplate.execute(
(RedisCallback<Long>) connection -> {
byte[] keyBytes = redisKey.getBytes(StandardCharsets.UTF_8);
byte[] tokenBytes = token.getBytes(StandardCharsets.UTF_8);
return (Long) connection.eval(
LUA_SCRIPT.getBytes(),
ReturnType.INTEGER,
1,
keyBytes,
tokenBytes
);
}
);
// 2. 如果返回0,说明Token不存在、不匹配或已被消费(重复请求)
if (result == null || result == 0) {
log.warn("重复支付请求或Token无效,userId: {}, token: {}", userId, token);
return false;
}
// 3. 执行业务逻辑(扣款、更新订单状态)
// ...
return true;
}
}
优点: 性能极高(Redis内存操作),支持高并发。 缺点: 依赖 Redis 高可用;需要解决 Redis 主从切换时的 Token 丢失(短暂不幂等)。
基于状态机(防重复更新)
适用场景: 订单状态流转(待支付->已支付->已发货),系统内部重试或消息队列重复消费时,防止状态回退或重复处理。
原理: 在更新 SQL 中增加 where status = 上一个状态 的条件,利用数据库行锁或乐观锁实现。
案例:防止发货逻辑重复执行
数据库表设计
CREATE TABLE `t_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL, `status` tinyint NOT NULL COMMENT '0待支付 1已支付 2已发货 3已完成', `version` int NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB;
发货 Service
@Service
public class OrderDeliveryService {
@Autowired
private OrderMapper orderMapper;
// 使用乐观锁版本号机制
public boolean deliverOrder(String orderNo, int currentVersion) {
// 关键SQL: update t_order set status = 2, version = version+1
// where order_no = ? and status = 1 and version = currentVersion
int affectedRows = orderMapper.updateStatusByOrderNoAndVersion(orderNo,
StatusEnum.PAID.getCode(), // 旧状态
StatusEnum.DELIVERED.getCode(), // 新状态
currentVersion);
// 影响行数为0,说明状态不对或已被修改(重复请求)
if (affectedRows == 0) {
log.warn("发货失败,订单状态异常或版本冲突: {}", orderNo);
return false;
}
// 执行后续业务(发送物流通知等)
return true;
}
}
优点: 天然防止状态回退,适合有严格状态流转的业务。 缺点: 需要业务方理解状态机,代码与状态耦合。
基于消息队列的幂等(去重表)
适用场景: 消息队列(RocketMQ / Kafka)消费端的重复消息处理。
原理: 消费端维护一张去重表(可放 Redis 或 DB),消息体里带唯一ID(如 orderNo + eventType),消费前先查询去重表是否存在,若存在则跳过。
案例:支付回调消息消费
去重表设计(Redis 实现)
@Service
public class PaymentCallbackConsumer {
@Autowired
private StringRedisTemplate redisTemplate;
// 消费消息
public void consumePaymentSuccess(PaymentMessage msg) {
String dedupKey = "dedup:payment:" + msg.getOrderNo() + ":" + msg.getEventType();
// 1. 先查 Redis 是否存在(原子操作)
Boolean absent = redisTemplate.opsForValue().setIfAbsent(dedupKey, "1", 1, TimeUnit.DAYS);
if (Boolean.FALSE.equals(absent)) {
log.info("重复的支付回调消息,已跳过: {}", dedupKey);
return;
}
// 2. 执行实际的业务逻辑(更新订单状态等)
// 注意:如果业务执行失败,需要删除该Key,以便重试(或使用定时任务清理)
try {
doBusiness(msg);
} catch (Exception e) {
// 业务失败,删除去重key,允许重试
redisTemplate.delete(dedupKey);
throw e;
}
}
private void doBusiness(PaymentMessage msg) {
// 更新订单状态等
}
}
优点: 轻量、性能高、易扩展。 缺点: Redis 主从切换时可能出现短暂重复(业务能接受即可)。
全局唯一ID + 幂等中间件(框架级别)
适用场景: 企业级、多个微服务共享的通用幂等组件。
原理: 封装一个公共组件,基于 注解 + AOP 实现,开发者只需在方法上加 @Idempotent,组件负责生成/校验幂等Key。
自定义注解实现
定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
// 幂等Key的表达式,支持SpEL
String key();
// 超时时间
long expire() default 60;
}
AOP 切面(核心)
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
// 1. 通过SpEL解析幂等Key
String key = parseKey(idempotent.key(), pjp);
String dedupKey = "idempotent:" + key;
// 2. 使用 setnx 尝试占位
Boolean success = redisTemplate.opsForValue().setIfAbsent(dedupKey, "1", idempotent.expire(), TimeUnit.SECONDS);
if (Boolean.FALSE.equals(success)) {
// 如果已经被处理,直接返回上一次的结果(或抛异常提示)
String resultStr = (String) redisTemplate.opsForValue().get(dedupKey + ":result");
if (resultStr != null) {
return JSON.parse(resultStr);
}
throw new RuntimeException("重复请求");
}
// 3. 执行业务
Object result = pjp.proceed();
// 4. 缓存结果(可选,用于后续直接返回)
redisTemplate.opsForValue().set(dedupKey + ":result", JSON.toJSONString(result), idempotent.expire(), TimeUnit.SECONDS);
return result;
}
}
使用方式:
@Service
public class OrderService {
@Idempotent(key = "#orderNo", expire = 60)
public Order createOrder(String orderNo, Long userId) {
// ...
}
}
总结与选型建议
| 方案 | 可靠性 | 性能 | 实现复杂度 | 推荐场景 |
|---|---|---|---|---|
| 数据库唯一索引 | ★★★★★ | ★★★ | ★★ | 核心交易、资金对账 |
| Redis + Token | ★★★★ | ★★★★★ | ★★★ | 高并发秒杀、表单防重复 |
| 状态机/乐观锁 | ★★★★★ | ★★★★ | ★★★ | 订单流转、物流更新 |
| 消息去重表 | ★★★★ | ★★★★ | ★★ | 消息队列消费端 |
| 注解+AOP组件 | ★★★★ | ★★★ | ★★★★★ | 企业级通用组件 |
落地建议:
- 核心交易:优先使用 数据库唯一索引 + 事务,这是底线。
- 高并发读多写少:使用 Redis 分布式锁,注意锁的原子性(Lua 脚本)和过期时间。
- 系统内部重试:使用 状态机(如
update ... where status=1),简单有效。 - 消息队列场景:使用 去重表,建议存储在 Redis(高性能)或专用去重库(强一致)。
- 不要依赖时间戳:时间戳在分布式环境下难以统一,务必使用唯一业务标识(如订单号)作为幂等Key。
幂等不一定是绝对的事后检查,也可以是事前的预防,设计业务表时,将关键业务字段(如订单号+用户ID)作为唯一索引,是成本最低、效果最好的方式。