JAVA 如何防止接口幂等导致重复下单?Redis + Token 验证机制
大家好,今天我们来聊聊一个在分布式系统中非常常见且重要的问题:如何防止接口幂等性,特别是针对电商场景下的重复下单问题。我们将重点探讨使用 Redis + Token 验证机制来解决这个问题。
什么是接口幂等性?
简单来说,幂等性是指一个操作,无论执行多少次,其结果都应该相同。更具体地说,对于一个接口,如果它是幂等的,那么调用一次和调用多次产生的副作用应该是一致的。
在电商系统中,支付、下单等关键接口必须保证幂等性。如果由于网络抖动、客户端重试等原因导致用户发起了多次下单请求,后端系统必须能够识别并处理这些重复请求,避免重复扣款、重复生成订单等问题。
为什么需要保证接口幂等性?
- 数据一致性: 避免数据库中出现重复数据,例如重复的订单记录。
- 资金安全: 避免重复扣款,保障用户的资金安全。
- 用户体验: 避免用户因为多次下单而产生困惑和不满。
- 系统稳定性: 减少不必要的资源消耗,提升系统整体的稳定性。
常见的幂等性解决方案
除了 Redis + Token 之外,还有一些其他的幂等性解决方案,例如:
- 数据库唯一索引: 利用数据库的唯一索引特性来防止重复数据插入。适用于插入操作。
- 乐观锁: 通过版本号机制来控制并发更新,防止数据被覆盖。适用于更新操作。
- 悲观锁: 在数据库层面加锁,保证同一时刻只有一个请求能够修改数据。适用于并发量不高,对数据一致性要求非常高的场景。
- 状态机: 通过定义状态转移图,保证操作的执行顺序和结果的唯一性。适用于有明确状态转换的业务场景。
每种方案都有其适用场景和优缺点,我们需要根据实际业务需求选择合适的方案。 今天我们主要聚焦Redis+Token方案。
Redis + Token 验证机制
这种方案的核心思想是:
- 生成 Token: 在用户请求下单接口之前,服务端生成一个唯一的 Token,并将其存储到 Redis 中,同时将 Token 返回给客户端。
- 请求携带 Token: 客户端在发起下单请求时,将 Token 放在请求参数或者请求头中。
- 验证 Token: 服务端接收到请求后,首先从请求中获取 Token,然后在 Redis 中查找该 Token 是否存在。
- 删除 Token: 如果 Token 存在,则从 Redis 中删除该 Token,并执行下单操作。
- 拒绝重复请求: 如果 Token 不存在,则说明该请求是重复请求,直接拒绝。
流程图:
+----------+ +----------+ +----------+ +----------+ +----------+
| Client |----->| Server |----->| Redis |----->| Server |----->| Database |
+----------+ +----------+ +----------+ +----------+ +----------+
| | Generate | | Store | | Execute | | |
| Get Token | Token |----->| Token | | Order |----->| Create |
| <------------| | | | | | | Order |
| | Return | | | | | | |
| | Token | | | | | | |
| | | | | | | | |
| Send Order | | | | | | | |
| with Token | | | | | | | |
|------------->| | | | | | | |
| | Validate | | Check | | | | |
| | Token |----->| Token | | | | |
| | | | | | | | |
| | Delete | | Delete | | | | |
| | Token |----->| Token | | | | |
| | | | | | | | |
| | | | | | | | |
| (Retry) | | | | | | | |
|------------->| Validate |----->| Check |----->| Reject | | |
| | Token | | Token | | Request | | |
| | | | | | | | |
代码示例:
首先,定义一个生成 Token 的方法:
import java.util.UUID;
public class TokenGenerator {
public static String generateToken() {
return UUID.randomUUID().toString();
}
}
接下来,展示如何在 Spring Boot 中实现 Redis + Token 验证机制:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.concurrent.TimeUnit;
@Service
public class OrderService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String TOKEN_PREFIX = "order_token:";
private static final long TOKEN_EXPIRATION_TIME = 60; // Token 过期时间,单位:秒
/**
* 生成下单 Token
* @param userId 用户ID
* @return Token
*/
public String generateOrderToken(String userId) {
String token = TokenGenerator.generateToken();
String key = TOKEN_PREFIX + userId;
redisTemplate.opsForValue().set(key, token, TOKEN_EXPIRATION_TIME, TimeUnit.SECONDS);
return token;
}
/**
* 创建订单
* @param userId 用户ID
* @param token Token
* @return 订单ID
*/
public String createOrder(String userId, String token) {
if (StringUtils.isEmpty(token)) {
throw new IllegalArgumentException("Token不能为空");
}
String key = TOKEN_PREFIX + userId;
String storedToken = redisTemplate.opsForValue().get(key);
if (StringUtils.isEmpty(storedToken)) {
throw new IllegalStateException("请勿重复提交"); // Token 不存在,说明是重复请求
}
if (!token.equals(storedToken)) {
throw new IllegalStateException("Token不匹配"); // Token 不匹配,可能是伪造的请求
}
// 原子性删除 Token
Boolean deleteResult = redisTemplate.delete(key);
if (deleteResult == null || !deleteResult) {
throw new IllegalStateException("并发操作,请稍后重试"); // 删除失败,可能是并发请求
}
// TODO: 执行下单逻辑,例如创建订单、扣减库存等
String orderId = UUID.randomUUID().toString(); // 模拟生成订单ID
System.out.println("用户 " + userId + " 创建订单,订单ID:" + orderId);
return orderId;
}
}
对应的 Controller 代码:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired
private OrderService orderService;
/**
* 获取下单 Token
* @param userId 用户ID
* @return Token
*/
@GetMapping("/token")
public String getOrderToken(@RequestParam String userId) {
return orderService.generateOrderToken(userId);
}
/**
* 创建订单
* @param userId 用户ID
* @param token Token
* @return 订单ID
*/
@PostMapping("/create")
public String createOrder(@RequestParam String userId, @RequestParam String token) {
return orderService.createOrder(userId, token);
}
}
代码解释:
TokenGenerator类: 负责生成唯一的 Token。OrderService类:generateOrderToken方法: 生成 Token,并将其存储到 Redis 中,设置过期时间。createOrder方法: 验证 Token,如果 Token 存在且匹配,则删除 Token 并执行下单逻辑;否则,拒绝请求。
OrderController类: 提供获取 Token 和创建订单的接口。
使用步骤:
- 用户访问下单页面,Controller 调用
orderService.generateOrderToken(userId)生成 Token,并将 Token 返回给用户。 - 用户在提交订单时,将 Token 放在请求参数中。
- Controller 调用
orderService.createOrder(userId, token)创建订单。 orderService.createOrder方法会验证 Token,如果验证通过,则执行下单逻辑;否则,返回错误信息。
关键点:
- Token 的唯一性: Token 必须是唯一的,可以使用 UUID 等算法生成。
- Token 的过期时间: Token 必须设置合理的过期时间,避免 Token 无限期存在 Redis 中。
- 原子性删除 Token: 删除 Token 的操作必须是原子性的,可以使用 Redis 的
DEL命令或者 Lua 脚本来实现。 - Redis 的选择: 建议使用 Redis 集群,保证 Redis 的高可用性。
- 错误处理: 需要处理各种异常情况,例如 Token 不存在、Token 不匹配、Redis 连接失败等。
优点:
- 实现简单,易于理解和维护。
- 对现有业务代码的侵入性较小。
- 性能较高,Redis 的读写速度非常快。
缺点:
- 需要依赖 Redis,增加了系统的复杂性。
- 如果 Redis 出现故障,可能会影响下单功能。
- Token 的存储和验证都需要消耗一定的 Redis 资源。
优化方案
- Token 加盐: 为了防止 Token 被破解,可以对 Token 进行加盐处理,例如使用 HMAC 算法。
- 防重放攻击: 为了防止重放攻击,可以在 Token 中加入时间戳,并设置 Token 的有效期。
- Lua 脚本: 可以使用 Lua 脚本将 Token 的验证和删除操作放在一个原子操作中执行,提高性能。
- Redisson 分布式锁: 如果对 Redis 的性能要求较高,可以使用 Redisson 分布式锁来保证原子性操作,避免使用 Lua 脚本。
Lua 脚本示例:
-- key: order_token:userId
-- arg1: token
local token = redis.call('get', KEYS[1])
if token and token == ARGV[1] then
redis.call('del', KEYS[1])
return 1
else
return 0
end
对应的 Java 代码:
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.annotation.PostConstruct;
import java.util.Collections;
@Service
public class OrderService {
@Autowired
private StringRedisTemplate redisTemplate;
private DefaultRedisScript<Long> script;
@PostConstruct
public void init() {
script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/check_and_delete_token.lua")));
script.setResultType(Long.class);
}
public String createOrderWithLua(String userId, String token) {
if (StringUtils.isEmpty(token)) {
throw new IllegalArgumentException("Token不能为空");
}
String key = "order_token:" + userId;
Long result = redisTemplate.execute(script, Collections.singletonList(key), token);
if (result == null || result == 0) {
throw new IllegalStateException("请勿重复提交");
}
// TODO: 执行下单逻辑
String orderId = java.util.UUID.randomUUID().toString();
System.out.println("用户 " + userId + " 创建订单,订单ID:" + orderId);
return orderId;
}
}
Redisson 分布式锁示例:
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.concurrent.TimeUnit;
@Service
public class OrderService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private RedissonClient redissonClient;
public String createOrderWithRedisson(String userId, String token) {
if (StringUtils.isEmpty(token)) {
throw new IllegalArgumentException("Token不能为空");
}
String key = "order_token:" + userId;
RLock lock = redissonClient.getLock("order_lock:" + userId);
try {
boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS); // 尝试获取锁,最多等待 10 秒,锁定时间 30 秒
if (locked) {
String storedToken = redisTemplate.opsForValue().get(key);
if (StringUtils.isEmpty(storedToken)) {
throw new IllegalStateException("请勿重复提交");
}
if (!token.equals(storedToken)) {
throw new IllegalStateException("Token不匹配");
}
redisTemplate.delete(key);
// TODO: 执行下单逻辑
String orderId = java.util.UUID.randomUUID().toString();
System.out.println("用户 " + userId + " 创建订单,订单ID:" + orderId);
return orderId;
} else {
throw new IllegalStateException("系统繁忙,请稍后重试");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("系统繁忙,请稍后重试");
} finally {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
如何选择合适的方案
选择合适的幂等性解决方案,需要根据实际业务场景进行评估。以下是一些建议:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 唯一索引 | 插入操作,且存在唯一标识的字段。 | 实现简单,利用数据库自身特性。 | 仅适用于插入操作,且需要依赖唯一标识字段。 |
| 乐观锁 | 更新操作,且并发量不高,允许一定概率的失败。 | 避免了数据库层面的锁,性能较好。 | 需要在代码中处理版本号冲突,逻辑相对复杂。 |
| 悲观锁 | 并发量不高,对数据一致性要求非常高的场景。 | 数据一致性高。 | 性能较差,容易造成死锁。 |
| 状态机 | 有明确状态转换的业务场景,例如订单状态的变更。 | 可以保证操作的执行顺序和结果的唯一性。 | 需要定义状态转移图,逻辑相对复杂。 |
| Redis + Token | 适用于各种场景,特别是需要防止重复提交的场景。 | 实现简单,易于理解和维护,性能较高。 | 需要依赖 Redis,增加了系统的复杂性。 |
总结一下
今天我们深入探讨了如何使用 Redis + Token 验证机制来防止接口幂等性导致的重复下单问题。我们介绍了幂等性的概念和重要性,分析了 Redis + Token 验证机制的原理和实现,并讨论了如何优化该方案。希望通过今天的讲解,大家能够更好地理解和应用幂等性解决方案,构建更加稳定和可靠的分布式系统。