幂等性是指对同一操作执行多次与执行一次的效果相同,不会因为重复执行而产生副作用。在实际应用中,由于网络延迟、用户重复点击提交、系统自动重试等原因,可能导致同一请求被多次发送到服务端处理,如果没有实现幂等性,就可能导致数据重复、业务异常等问题。
1. 基于Token令牌的幂等性实现
Token令牌策略是最常见的幂等性实现方式之一,其核心思想是在执行业务操作前先获取一个唯一token,然后在调用接口时将其随请求一起提交,服务端校验并销毁token,确保其只被使用一次。
实现步骤
- 客户端先调用获取token接口
- 服务端生成唯一token并存入Redis,设置过期时间
- 客户端调用业务接口时附带token参数
- 服务端验证token存在性并删除,防止重复使用
代码实现
less
@RestController
@RequestMapping("/api")
public class OrderController {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private OrderService orderService;
// 获取token接口
@GetMapping("/token")
public Result<String> getToken() {
// 生成唯一token
String token = UUID.randomUUID().toString();
// 存入Redis并设置过期时间
redisTemplate.opsForValue().set("idempotent:token:" + token, "1", 10, TimeUnit.MINUTES);
return Result.success(token);
}
// 创建订单接口
@PostMapping("/order")
public Result<Order> createOrder(@RequestHeader("Idempotent-Token") String token, @RequestBody OrderRequest request) {
// 检查token是否存在
String key = "idempotent:token:" + token;
Boolean exist = redisTemplate.hasKey(key);
if (exist == null || !exist) {
return Result.fail("令牌不存在或已过期");
}
// 删除token,保证幂等性
if (Boolean.FALSE.equals(redisTemplate.delete(key))) {
return Result.fail("令牌已被使用");
}
// 执行业务逻辑
Order order = orderService.createOrder(request);
return Result.success(order);
}
}
通过AOP简化实现
可以通过自定义注解和AOP进一步简化幂等性实现:
less
// 自定义幂等性注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
long timeout() default 10; // 过期时间,单位分钟
}
// AOP实现
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 获取请求头中的token
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String token = request.getHeader("Idempotent-Token");
if (StringUtils.isEmpty(token)) {
throw new BusinessException("幂等性Token不能为空");
}
String key = "idempotent:token:" + token;
Boolean exist = redisTemplate.hasKey(key);
if (exist == null || !exist) {
throw new BusinessException("令牌不存在或已过期");
}
// 删除token,保证幂等性
if (Boolean.FALSE.equals(redisTemplate.delete(key))) {
throw new BusinessException("令牌已被使用");
}
// 执行目标方法
return joinPoint.proceed();
}
}
// 控制器使用注解
@RestController
@RequestMapping("/api")
public class OrderController {
@Autowired
private OrderService orderService;
@PostMapping("/order")
@Idempotent(timeout = 30)
public Result<Order> createOrder(@RequestBody OrderRequest request) {
Order order = orderService.createOrder(request);
return Result.success(order);
}
}
优缺点分析
优点
- 实现简单,易于理解
- 对业务代码侵入小,可通过AOP实现
- 可以预先生成token,减少请求处理时的延迟
缺点
- 需要两次请求才能完成一次业务操作
- 增加了客户端的复杂度
- 依赖Redis等外部存储
2. 基于数据库唯一约束的幂等性实现
利用数据库的唯一约束特性可以简单有效地实现幂等性。当尝试插入重复数据时,数据库会抛出唯一约束异常,我们可以捕获这个异常并进行合适的处理。
实现方式
- 在关键业务表上添加唯一索引
- 在插入数据时捕获唯一约束异常
- 根据业务需求决定是返回错误还是返回已存在的数据
代码实现
typescript
@Service
public class PaymentServiceImpl implements PaymentService {
@Autowired
private PaymentRepository paymentRepository;
@Transactional
@Override
public PaymentResponse processPayment(PaymentRequest request) {
try {
// 创建支付记录,包含唯一业务标识
Payment payment = new Payment();
payment.setOrderNo(request.getOrderNo());
payment.setTransactionId(request.getTransactionId()); // 唯一交易ID
payment.setAmount(request.getAmount());
payment.setStatus(PaymentStatus.PROCESSING);
payment.setCreateTime(new Date());
// 保存支付记录
paymentRepository.save(payment);
// 调用支付网关API
// ...支付处理逻辑...
// 更新支付状态
payment.setStatus(PaymentStatus.SUCCESS);
paymentRepository.save(payment);
return new PaymentResponse(true, "支付成功", payment.getId());
} catch (DataIntegrityViolationException e) {
// 捕获唯一约束异常
if (e.getCause() instanceof ConstraintViolationException) {
// 幂等性处理 - 查询已存在的支付记录
Payment existingPayment = paymentRepository
.findByTransactionId(request.getTransactionId())
.orElse(null);
if (existingPayment != null) {
if (PaymentStatus.SUCCESS.equals(existingPayment.getStatus())) {
// 支付已成功处理,返回成功结果
return new PaymentResponse(true, "支付已处理", existingPayment.getId());
} else {
// 支付正在处理中,返回适当提示
return new PaymentResponse(false, "支付处理中", existingPayment.getId());
}
}
}
// 其他数据完整性问题
log.error("支付失败", e);
return new PaymentResponse(false, "支付失败", null);
}
}
}
// 支付实体类
@Entity
@Table(name = "payments")
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNo;
@Column(unique = true) // 唯一约束
private String transactionId;
private BigDecimal amount;
@Enumerated(EnumType.STRING)
private PaymentStatus status;
private Date createTime;
// Getters and setters...
}
优缺点分析
优点
- 实现简单,利用数据库已有特性
- 无需额外的存储组件
- 强一致性保证
缺点
- 依赖数据库的唯一约束特性
- 可能导致频繁的异常处理
- 在高并发情况下可能成为性能瓶颈
3. 基于分布式锁的幂等性实现
分布式锁是实现幂等性的另一种有效方式,特别适合于高并发场景。通过对业务唯一标识加锁,可以确保同一时间只有一个请求能够执行业务逻辑。
实现方式
- 使用Redis、Zookeeper等实现分布式锁
- 以请求的唯一标识作为锁的key
- 在业务处理前获取锁,处理完成后释放锁
基于Redis的分布式锁实现
java
@Service
public class InventoryServiceImpl implements InventoryService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private InventoryRepository inventoryRepository;
private static final String LOCK_PREFIX = "inventory:lock:";
private static final long LOCK_EXPIRE = 10000; // 10秒
@Override
public DeductResponse deductInventory(DeductRequest request) {
String lockKey = LOCK_PREFIX + request.getRequestId();
String requestId = UUID.randomUUID().toString();
try {
// 尝试获取分布式锁
Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, LOCK_EXPIRE, TimeUnit.MILLISECONDS);
if (Boolean.FALSE.equals(acquired)) {
// 获取锁失败,说明可能是重复请求
return new DeductResponse(false, "请求正在处理中,请勿重复提交");
}
// 查询是否已处理过该请求
Optional<InventoryRecord> existingRecord = inventoryRepository.findByRequestId(request.getRequestId());
if (existingRecord.isPresent()) {
// 幂等性控制 - 请求已处理过
return new DeductResponse(true, "库存已扣减", existingRecord.get().getId());
}
// 执行库存扣减逻辑
Inventory inventory = inventoryRepository.findByProductId(request.getProductId())
.orElseThrow(() -> new BusinessException("商品不存在"));
if (inventory.getStock() < request.getQuantity()) {
throw new BusinessException("库存不足");
}
// 扣减库存
inventory.setStock(inventory.getStock() - request.getQuantity());
inventoryRepository.save(inventory);
// 记录库存操作
InventoryRecord record = new InventoryRecord();
record.setRequestId(request.getRequestId());
record.setProductId(request.getProductId());
record.setQuantity(request.getQuantity());
record.setCreateTime(new Date());
inventoryRepository.save(record);
return new DeductResponse(true, "库存扣减成功", record.getId());
} catch (BusinessException e) {
return new DeductResponse(false, e.getMessage(), null);
} catch (Exception e) {
log.error("库存扣减失败", e);
return new DeductResponse(false, "库存扣减失败", null);
} finally {
// 释放锁,注意只释放自己的锁
if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) {
redisTemplate.delete(lockKey);
}
}
}
}
使用Redisson简化实现
java
@Service
public class InventoryServiceImpl implements InventoryService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private InventoryRepository inventoryRepository;
private static final String LOCK_PREFIX = "inventory:lock:";
@Override
public DeductResponse deductInventory(DeductRequest request) {
String lockKey = LOCK_PREFIX + request.getRequestId();
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁,等待5秒,锁过期时间10秒
boolean acquired = lock.tryLock(5, 10, TimeUnit.SECONDS);
if (!acquired) {
return new DeductResponse(false, "请求正在处理中,请勿重复提交");
}
// 查询是否已处理过该请求
// ...后续业务逻辑与前面例子相同...
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new DeductResponse(false, "请求被中断", null);
} catch (Exception e) {
log.error("库存扣减失败", e);
return new DeductResponse(false, "库存扣减失败", null);
} finally {
// 释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
优缺点分析
优点
- 适用于高并发场景
- 可以与其他幂等性策略结合使用
- 提供较好的实时性控制
缺点
- 实现复杂度较高
- 依赖外部存储服务
4. 基于请求内容摘要的幂等性实现
这种方案通过计算请求内容的哈希值或摘要,生成唯一标识作为幂等键,确保相同内容的请求只处理一次。
实现方式
- 计算请求参数的摘要值(如MD5, SHA-256等)
- 将摘要值作为幂等键存储在Redis或数据库中
- 请求处理前先检查该摘要值是否已存在
- 存在则表示重复请求,不执行业务逻辑
代码实现
typescript
@RestController
@RequestMapping("/api")
public class TransferController {
@Autowired
private TransferService transferService;
@Autowired
private StringRedisTemplate redisTemplate;
@PostMapping("/transfer")
public Result<TransferResult> transfer(@RequestBody TransferRequest request) {
// 生成请求摘要作为幂等键
String idempotentKey = generateIdempotentKey(request);
String redisKey = "idempotent:digest:" + idempotentKey;
// 尝试在Redis中设置幂等键,使用SetNX操作确保原子性
Boolean isFirstRequest = redisTemplate.opsForValue()
.setIfAbsent(redisKey, "processed", 24, TimeUnit.HOURS);
// 如果键已存在,说明是重复请求
if (Boolean.FALSE.equals(isFirstRequest)) {
// 查询处理结果(也可以直接存储处理结果)
TransferRecord record = transferService.findByIdempotentKey(idempotentKey);
if (record != null) {
// 返回之前的处理结果
return Result.success(new TransferResult(
record.getTransactionId(),
"交易已处理",
record.getAmount(),
record.getStatus()));
} else {
// 幂等键存在但找不到记录,可能正在处理
return Result.fail("请求正在处理中,请勿重复提交");
}
}
try {
// 执行转账业务逻辑
TransferResult result = transferService.executeTransfer(request, idempotentKey);
return Result.success(result);
} catch (Exception e) {
// 处理失败时,删除幂等键,允许客户端重试
// 或者可以保留键但记录失败状态,取决于业务需求
redisTemplate.delete(redisKey);
return Result.fail("转账处理失败: " + e.getMessage());
}
}
/**
* 生成请求内容摘要作为幂等键
*/
private String generateIdempotentKey(TransferRequest request) {
// 组合关键字段,确保能唯一标识业务操作
String content = request.getFromAccount()
+ "|" + request.getToAccount()
+ "|" + request.getAmount().toString()
+ "|" + request.getRequestTime();
// 计算MD5摘要
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("生成幂等键失败", e);
}
}
}
@Service
public class TransferServiceImpl implements TransferService {
@Autowired
private TransferRecordRepository transferRecordRepository;
@Autowired
private AccountRepository accountRepository;
@Override
@Transactional
public TransferResult executeTransfer(TransferRequest request, String idempotentKey) {
// 执行转账业务逻辑
// 1. 检查账户余额
// 2. 扣减来源账户
// 3. 增加目标账户
// 生成交易ID
String transactionId = UUID.randomUUID().toString();
// 保存交易记录,包含幂等键
TransferRecord record = new TransferRecord();
record.setTransactionId(transactionId);
record.setFromAccount(request.getFromAccount());
record.setToAccount(request.getToAccount());
record.setAmount(request.getAmount());
record.setIdempotentKey(idempotentKey);
record.setStatus(TransferStatus.SUCCESS);
record.setCreateTime(new Date());
transferRecordRepository.save(record);
return new TransferResult(
transactionId,
"转账成功",
request.getAmount(),
TransferStatus.SUCCESS);
}
@Override
public TransferRecord findByIdempotentKey(String idempotentKey) {
return transferRecordRepository.findByIdempotentKey(idempotentKey).orElse(null);
}
}
// 转账记录实体
@Entity
@Table(name = "transfer_records", indexes = {
@Index(name = "idx_idempotent_key", columnList = "idempotent_key", unique = true)
})
public class TransferRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String transactionId;
private String fromAccount;
private String toAccount;
private BigDecimal amount;
@Column(name = "idempotent_key")
private String idempotentKey;
@Enumerated(EnumType.STRING)
private TransferStatus status;
private Date createTime;
// Getters and setters...
}
使用自定义注解简化实现
less
// 自定义幂等性注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 过期时间(秒)
*/
int expireSeconds() default 86400; // 默认24小时
/**
* 幂等键来源,可从请求体、请求参数等提取
*/
KeySource source() default KeySource.REQUEST_BODY;
/**
* 提取参数的表达式(如SpEL表达式)
*/
String[] expression() default {};
enum KeySource {
REQUEST_BODY, // 请求体
PATH_VARIABLE, // 路径变量
REQUEST_PARAM, // 请求参数
CUSTOM // 自定义
}
}
// AOP实现
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 获取请求参数
Object[] args = joinPoint.getArgs();
// 根据注解配置生成幂等键
String idempotentKey = generateKey(joinPoint, idempotent);
String redisKey = "idempotent:digest:" + idempotentKey;
// 检查是否重复请求
Boolean setSuccess = redisTemplate.opsForValue()
.setIfAbsent(redisKey, "processing", idempotent.expireSeconds(), TimeUnit.SECONDS);
if (Boolean.FALSE.equals(setSuccess)) {
// 获取存储的处理结果
String value = redisTemplate.opsForValue().get(redisKey);
if ("processing".equals(value)) {
throw new BusinessException("请求正在处理中,请勿重复提交");
} else if (value != null) {
// 已处理,返回缓存的结果
return JSON.parseObject(value, Object.class);
}
}
try {
// 执行实际方法
Object result = joinPoint.proceed();
// 存储处理结果
redisTemplate.opsForValue().set(redisKey, JSON.toJSONString(result),
idempotent.expireSeconds(), TimeUnit.SECONDS);
return result;
} catch (Exception e) {
// 处理失败,删除键允许重试
redisTemplate.delete(redisKey);
throw e;
}
}
/**
* 根据注解配置生成幂等键
*/
private String generateKey(ProceedingJoinPoint joinPoint, Idempotent idempotent) {
// 提取请求参数,根据KeySource和expression生成摘要
// 实际实现会更复杂,这里简化
String content = "";
// 计算MD5摘要
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("生成幂等键失败", e);
}
}
}
// 控制器使用注解
@RestController
@RequestMapping("/api")
public class TransferController {
@Autowired
private TransferService transferService;
@PostMapping("/transfer")
@Idempotent(expireSeconds = 3600, source = KeySource.REQUEST_BODY,
expression = {"fromAccount", "toAccount", "amount", "requestTime"})
public Result<TransferResult> transfer(@RequestBody TransferRequest request) {
// 执行转账业务逻辑
TransferResult result = transferService.executeTransfer(request);
return Result.success(result);
}
}
优缺点分析
优点
- 方案更通用
- 实现相对简单,易于集成
- 对客户端友好,不需要额外的token请求
缺点
- 哈希计算有一定性能开销
- 表单数据顺序变化可能导致不同的摘要值
总结
幂等性设计是系统稳定性和可靠性的重要保障,通过合理选择和实现幂等性策略,可以有效防止因重复请求导致的数据不一致问题。在实际项目中,应根据具体的业务需求和系统架构,选择最适合的幂等性实现方案。