构建高可用系统:基于Redis与AOP的API幂等性设计与实战
引言
在现代微服务架构中,系统间的通信错综复杂。网络延迟、用户重复点击、消息队列的重试机制,都可能导致同一个请求被多次发送。想象一个电商平台的下单场景:用户在网络不佳时点击“提交订单”按钮,由于迟迟没有收到响应,便再次点击。如果后端接口没有做任何防护,这两次请求可能会被系统处理两次,导致创建了两个完全相同的订单。这不仅会给用户带来困扰,更会引发库存超卖、支付异常等一系列严重的业务问题。这类“重复提交”问题,在技术上被称为“接口幂等性”问题。
本文将围绕电商“创建订单”这一核心业务场景,剖析其面临的幂等性挑战。我们将采用一套业界主流且高效的技术栈组合:Spring Boot + Redis + AOP,来设计并实现一个通用的、对业务代码无侵入的幂等性解决方案,旨在为有1-5年经验的Java后端开发者提供一套完整的、可落地的接口幂等性防护思路。
整体架构设计
我们设定系统为一套典型的微服务架构。客户端(App/Web)通过API网关访问后端的订单服务。为了解决分布式环境下的幂等性问题,我们引入Redis作为外部依赖,用于存储请求的唯一标识。

架构核心思想:
该架构的核心在于引入了一个“预请求”阶段来获取幂等性令牌(Token)。
- 令牌获取:在执行核心业务操作(如创建订单)前,客户端必须先向服务端请求一个一次性的、有时效的幂等Token。
- 令牌验证:客户端在发起业务请求时,必须在请求头(Header)中携带此Token。订单服务在接收到请求后,会首先通过AOP拦截器验证Token的有效性。它会尝试从Redis中删除该Token,利用Redis命令的原子性来保证并发安全。如果删除成功,证明是第一次请求,则放行执行业务逻辑;如果删除失败,说明该Token已被使用或不存在,直接拒绝该请求,从而防止重复执行。
这种架构将幂等性校验逻辑与业务逻辑完全解耦,使其能有效应对因网络重试或用户重复操作引发的挑战。
核心技术选型与理由
- Spring Boot: 提供快速的应用开发框架,简化了配置和依赖管理。其强大的AOP(面向切面编程)支持是实现非侵入式拦截器的基础。
- Redis: 基于内存的高性能键值数据库。我们选择它的核心理由有两点:
- 高性能: 内存读写速度极快,能够支撑高并发下的Token验证,不会成为系统瓶颈。
- 原子操作: Redis的
SETNX或DEL等命令是原子性的,这对于在分布式环境下确保“检查并设置”或“检查并删除”操作的唯一性至关重要,避免了复杂的分布式锁。
- Spring AOP: 面向切面编程允许我们将幂等性校验这种横切关注点(Cross-cutting Concern)从业务代码中分离出来。通过自定义注解和切面,我们可以优雅地为任意需要幂等保护的Controller方法开启防护,而无需修改方法内部的任何代码。
关键实现步骤与代码详解
下面,我们分步展示如何用Java代码实现这套幂等性保护机制。
第1步:添加Maven依赖
在pom.xml中,确保包含了Web、Redis和AOP的相关依赖。
<!-- Spring Boot Web Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Spring Boot AOP Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Apache Commons Lang for random strings -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
第2步:定义幂等性注解
创建一个自定义注解@Idempotent,用于标记需要进行幂等性校验的接口方法。
package com.example.idempotent.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 幂等性保护注解
* 标记在Controller的方法上,启用幂等性校验
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
}
第3步:实现Token服务
TokenService负责生成唯一Token并与Redis交互。
package com.example.idempotent.service;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class TokenService {
@Autowired
private StringRedisTemplate redisTemplate;
// Token在Redis中的key前缀
private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent:token:";
private static final String USED_PREFIX = "idempotent:used:";
/**
* 创建并存储一个新的幂等Token
* 并绑定用户
* @return 生成的Token字符串
*/
public String createToken(String userId) {
// 1. 生成一个足够随机的字符串作为Token
String token = UUID.randomUUID().toString().replace("-", "");
String key = TOKEN_PREFIX + token;
// 2. 将Token存入Redis,并设置一个合理的过期时间(例如30分钟)
// 过期时间应大于正常业务操作的耗时
TokenInfo tokenInfo = new TokenInfo(userId, System.currentTimeMillis());
redisTemplate.opsForValue().set(key, JSON.toJSONString(tokenInfo), 30, TimeUnit.MINUTES);
return token;
}
/**
* 校验Token的有效性
* @param token 待校验的Token
* @param userId 用户Id
* @return true - 校验通过, false - 校验失败 (Token不存在或已被使用)
*/
public boolean validateAndConsumeToken(String token, String userId) {
if (StringUtils.isBlank(token) || StringUtils.isBlank(userId)) {
return false;
}
String key = TOKEN_PREFIX + token;
String usedKey = USED_PREFIX + token;
// 使用Lua脚本保证原子性
String luaScript =
"local token_key = KEYS[1] " +
"local used_key = KEYS[2] " +
"local user_id = ARGV[1] " +
"local token_info = redis.call('GET', token_key) " +
"if token_info then " +
" local info = cjson.decode(token_info) " +
" if info.userId == user_id then " +
" redis.call('DEL', token_key) " +
" redis.call('SET', used_key, '1', 'EX', 300) " +
" return 1 " +
" end " +
"end " +
"return 0";
Long result = redisTemplate.execute(
(RedisCallback<Long>) connection ->
connection.eval(luaScript.getBytes(), ReturnType.INTEGER, 2,
key.getBytes(), usedKey.getBytes(), userId.getBytes())
);
return result != null && result == 1;
}
}
第4步:实现AOP拦截器
IdempotentInterceptor是实现幂等性校验的核心。它是一个切面,会在执行被@Idempotent注解标记的方法前进行拦截。
package com.example.idempotent.interceptor;
import com.example.idempotent.annotation.Idempotent;
import com.example.idempotent.service.TokenService;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
@Aspect
@Component
public class IdempotentInterceptor {
@Autowired
private TokenService tokenService;
// 定义切点,拦截所有被@Idempotent注解标记的方法
@Pointcut("@annotation(idempotent)")
public void idempotentPointcut(Idempotent idempotent) {}
// 环绕通知
@Around("idempotentPointcut(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
throw new IllegalStateException("Cannot get request attributes");
}
HttpServletRequest request = attributes.getRequest();
// 从请求头中获取幂等Token
String token = request.getHeader("X-Idempotent-Token");
// 调用TokenService进行校验
if (tokenService.checkToken(token)) {
// 校验成功,放行,执行原始方法
return joinPoint.proceed();
} else {
// 校验失败,抛出异常,提示重复提交
throw new RuntimeException("重复提交,请勿重试!");
}
}
}
第5步:在Controller中使用
最后,我们在Controller中提供一个获取Token的接口和一个受保护的业务接口。
package com.example.idempotent.controller;
import com.example.idempotent.annotation.Idempotent;
import com.example.idempotent.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class OrderController {
@Autowired
private TokenService tokenService;
@Autowired
private OrderService orderService;
/**
* 接口1: 获取幂等Token
*/
@GetMapping("/idempotent-token")
public Result<String> getIdempotentToken(HttpServletRequest request) {
// 从JWT或Session中获取用户ID
String userId = getCurrentUserId(request);
if (StringUtils.isBlank(userId)) {
return Result.error("用户未登录");
}
String token = tokenService.createToken(userId);
return Result.success(token);
}
/**
* 接口2: 创建订单 (受幂等性保护)
* @param orderData 订单数据
* @return 处理结果
*/
@PostMapping("/orders")
@Idempotent // 加上此注解,启用幂等性校验
public Result<OrderVO> createOrder(@RequestBody CreateOrderRequest request,
HttpServletRequest httpRequest) {
try {
String userId = getCurrentUserId(httpRequest);
OrderVO order = orderService.createOrder(userId, request);
return Result.success(order);
} catch (BusinessException e) {
return Result.error(e.getMessage());
}
}
private String getCurrentUserId(HttpServletRequest request) {
// 从JWT Token或Session中解析用户ID
// 这里简化处理
return request.getHeader("User-Id");
}
}
测试与质量保证
对这套机制的测试至关重要,需要覆盖以下几个方面:
- 单元测试: 使用JUnit和Mockito,对
TokenService进行测试。可以模拟redisTemplate的行为,验证createToken和checkToken的逻辑是否正确。 - 切面测试: 对
IdempotentInterceptor进行测试,验证其是否能正确拦截请求、提取Token,并根据checkToken的返回结果决定是放行还是抛出异常。 - 集成测试: 启动完整的Spring Boot应用,使用
MockMvc或RestAssured等工具模拟HTTP请求。编写测试用例,覆盖以下场景:- 不带Token请求,应失败。
- 带错误Token请求,应失败。
- 带正确Token第一次请求,应成功。
- 使用同一个Token第二次请求,应失败。
- 并发测试: 使用JMeter或
CountDownLatch等工具,模拟多个线程同时使用同一个Token请求业务接口,验证是否只有一个请求成功。
总结与展望
本文通过一个典型的电商下单场景,展示了接口幂等性问题带来的风险,并提供了一套基于 Spring Boot + Redis + AOP 的通用解决方案。该方案通过“Token预申请”和“原子化消耗”的机制,实现了对业务代码无侵入的幂等性保护,有效提升了系统的健壮性和数据一致性。
当然,幂等性的实现方案并非只有这一种。在其他场景下,也可以考虑使用数据库唯一索引约束(针对具有天然唯一业务标识的场景)、状态机机制(在多步骤流程中,根据状态判断是否可执行)或分布式锁等方式。选择哪种方案,需要根据具体的业务需求、性能要求和技术栈来综合权衡。希望本文能为你构建更可靠、更稳定的分布式系统提供有价值的参考。

3197

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



