方案概览:
Java单体架构项目,实现限时抢购:前端要做到动静分离,降低刷新秒杀页面对服务器的压力。防止脚本抢购,用户点击抢购按钮,前端先携带抢购商品id向后端请求验证码图片,抢购商品id不同于商品id,它所属于用于抢购的表,后端生成6位随机验证码,将6位码存Redis的String,key是前缀加用户id加抢购商品id,value是6位码,过期时间60秒,然后根据这个6位码使用Kaptcha生成扭曲图片并返回给前端图片二进制流。
用户输入验证码后,前端才携带用户输入的6位码和抢购商品id调用后端的抢购接口,后端先查询验证码是否存在,因为不区分大小写,所以验证码对比时都取小写,如果不存在,则返回验证码错误,前端就再次调用请求验证码的接口;如果存在,为防止超卖,就用Redis的String预减库存,key是前缀加抢购商品id,value是剩余数量,同时还要设置一种Set,key是前缀加用户id,value是抢购商品id,用于检测当前用户是否已经抢购过这个商品。
首先判断用户是否抢购过这个商品,有的话直接返回已经抢购过了,没的话判断该商品是否还有货,没货直接返回已经售空,有货的话令String记录的剩余数量减1并在Set记录用户已抢了这个商品,这个过程使用lua脚本保证原子性。然后使用RocketMQ创建普通消息,执行剩余业务逻辑,如下单等,实现流量的削峰填谷。使用Sentinel进行限流操作,因为是单机部署,所以创建验证码qps限流1000,抢购qps限流2500。限时抢购活动结束时清理用于预减库存和重复抢购的key。
以下是实现该方案的具体操作:
1. 前端动静分离
- 静态资源:将HTML/CSS/JS部署到Nginx/CDN
- 动态接口:通过Ajax调用后端API
- 秒杀页面示例:
// 点击抢购按钮时请求验证码 document.getElementById('seckill-btn').onclick = function() { const secKillId = 123; // 抢购商品ID fetch(`/captcha?secKillId=${secKillId}`) .then(response => response.blob()) .then(blob => { document.getElementById('captcha-img').src = URL.createObjectURL(blob); }); }; // 提交抢购请求 function submitSeckill() { const secKillId = 123; const captcha = document.getElementById('captcha-input').value; fetch('/seckill', { method: 'POST', body: JSON.stringify({ secKillId, captcha }), headers: {'Content-Type': 'application/json'} }).then(handleResponse); }
2. 验证码生成接口
@GetMapping("/captcha")
public void generateCaptcha(
@RequestParam("secKillId") Long secKillId,
HttpServletResponse response) {
// 1. 生成6位随机验证码(转小写)
String captcha = RandomStringUtils.randomAlphanumeric(6).toLowerCase();
// 2. 存储Redis:key= captcha:{userId}:{secKillId}
String redisKey = "captcha:" + getCurrentUserId() + ":" + secKillId;
redisTemplate.opsForValue().set(redisKey, captcha, 60, TimeUnit.SECONDS);
// 3. 生成图片并返回
ByteArrayOutputStream os = new ByteArrayOutputStream();
kaptchaProducer.createImage(captcha).writeTo(os);
response.getOutputStream().write(os.toByteArray());
}
3. 抢购接口实现(核心逻辑)
@PostMapping("/seckill")
public SeckillResponse executeSeckill(@RequestBody SeckillRequest request) {
// 1. 验证码校验(忽略大小写)
String captchaKey = "captcha:" + getCurrentUserId() + ":" + request.getSecKillId();
String storedCaptcha = redisTemplate.opsForValue().get(captchaKey);
if (storedCaptcha == null || !storedCaptcha.equals(request.getCaptcha().toLowerCase())) {
return SeckillResponse.fail("验证码错误");
}
// 2. 执行Lua脚本保证原子性
Long result = executeLuaScript(request.getSecKillId(), getCurrentUserId());
// 3. 处理脚本结果
if (result == 1) {
return SeckillResponse.fail("库存不足");
} else if (result == 2) {
return SeckillResponse.fail("请勿重复抢购");
}
// 4. 发送MQ消息异步处理订单
rocketMQTemplate.send("seckill_order_topic",
MessageBuilder.withPayload(buildOrderMessage(request)).build());
return SeckillResponse.success("抢购成功");
}
// Lua脚本(resources/seckill.lua)
private final DefaultRedisScript<Long> SECKILL_SCRIPT = new DefaultRedisScript<>(
"local stockKey = KEYS[1] " +
"local userKey = KEYS[2] " +
"local secKillId = ARGV[1] " +
"local userId = ARGV[2] " +
-- 检查是否已抢购
"if redis.call('SISMEMBER', userKey, secKillId) == 1 then " +
"return 2 " +
"end " +
-- 检查库存
"local stock = tonumber(redis.call('GET', stockKey)) " +
"if stock <= 0 then " +
"return 1 " +
"end " +
-- 扣减库存并记录用户
"redis.call('DECR', stockKey) " +
"redis.call('SADD', userKey, secKillId) " +
"return 0",
Long.class
);
private Long executeLuaScript(Long secKillId, Long userId) {
List<String> keys = Arrays.asList(
"stock:" + secKillId, // 库存Key
"user_seckill:" + userId // 用户抢购记录Key
);
return redisTemplate.execute(
SECKILL_SCRIPT,
keys,
secKillId.toString(),
userId.toString()
);
}
4. RocketMQ消费者处理订单
@RocketMQMessageListener(topic = "seckill_order_topic", consumerGroup = "seckill_group")
public class OrderConsumer implements RocketMQListener<OrderMessage> {
@Override
public void onMessage(OrderMessage message) {
// 1. 数据库减库存(幂等操作)
seckillService.reduceStock(message.getSecKillId());
// 2. 创建订单
orderService.createOrder(message.getUserId(), message.getSecKillId());
}
}
5. Sentinel限流配置
// 验证码接口限流1000QPS
@PostConstruct
public void initFlowRules() {
List<FlowRule> rules = new ArrayList<>();
// 验证码限流
FlowRule captchaRule = new FlowRule();
captchaRule.setResource("captchaApi");
captchaRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
captchaRule.setCount(1000);
rules.add(captchaRule);
// 抢购接口限流2500QPS
FlowRule seckillRule = new FlowRule();
seckillRule.setResource("seckillApi");
seckillRule.setCount(2500);
rules.add(seckillRule);
FlowRuleManager.loadRules(rules);
}
// 接口资源注解
@SentinelResource(value = "captchaApi", blockHandler = "handleBlock")
@GetMapping("/captcha")
public void generateCaptcha(...) { ... }
@SentinelResource("seckillApi")
@PostMapping("/seckill")
public SeckillResponse executeSeckill(...) { ... }
6. 活动结束清理Redis
public void clearSeckillKeys(Long secKillId) {
// 1. 删除所有库存Key
redisTemplate.delete("stock:" + secKillId);
// 2. 扫描删除用户抢购记录(使用SCAN避免阻塞)
String pattern = "user_seckill:*";
RedisConnection conn = redisTemplate.getConnectionFactory().getConnection();
try (Cursor<byte[]> cursor = conn.scan(ScanOptions.scanOptions().match(pattern).build())) {
while (cursor.hasNext()) {
String key = new String(cursor.next());
conn.del(key.getBytes());
}
}
}
关键点说明
-
原子性保障
- 使用Lua脚本确保库存扣减和用户记录添加的原子操作
-
防超卖设计
- Redis预减库存 + 数据库最终扣减(MQ消费)
-
防脚本机制
- 验证码流程强制人机交互
- 验证码绑定用户与商品
-
流量削峰
- RocketMQ异步化订单处理
- Sentinel限流保护系统
-
数据清理
- 活动结束后批量删除Redis相关Key(SCAN遍历大Key)
部署注意事项
- Redis需配置持久化防止宕机数据丢失
- RocketMQ开启持久化+主从复制
- Nginx配置静态资源缓存
- 使用
spring-boot-starter-data-redis和rocketmq-spring-boot-starter简化集成
通过以上实现,系统可支撑高并发抢购场景,同时有效防止脚本作弊和超卖问题。