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 验证机制的原理和实现,并讨论了如何优化该方案。希望通过今天的讲解,大家能够更好地理解和应用幂等性解决方案,构建更加稳定和可靠的分布式系统。