一、核心挑战与目标
- 高并发场景:瞬间流量可能达到数十万甚至百万级请求,传统数据库直接操作会导致瓶颈。
- 超卖问题:必须严格保证库存不会变为负数(即实际销量≤总库存)。
- 重复下单拦截:同一用户多次点击只能成功一次。
- 系统稳定性:避免因过载导致服务雪崩或数据库连接池耗尽。
- 公平性保障:通过限流、验证码等手段防止机器人刷单。
二、整体架构分层设计
✅ 前端层防护
- 按钮置灰:用户点击后立即禁用按钮,防止重复提交。
- 人机验证:集成滑块验证或短信验证码分流无效请求。
- 本地限流:使用
Guava RateLimiter
限制单个用户的请求频率。
⬇️ CDN加速静态资源
- 商品图片、页面样式文件由CDN分发,减轻应用服务器压力。
🔧 API网关层过滤
- Sentinel/Redis限流:基于IP或用户ID进行动态流控。
- JWT鉴权:验证请求合法性,拒绝未授权访问。
- 参数校验:过滤非法参数(如负数库存)。
⚙️ 应用服务集群
- 多实例部署:通过负载均衡算法分散流量到不同节点。
- 异步解耦:将下单流程拆解为“预扣库存→异步落单”,利用消息队列削峰填谷。
💾 缓存层核心作用
- Redis预加载库存:活动开始前将商品信息全量同步至内存数据库。
- 分布式锁机制:使用
SETNX
或Lua脚本实现原子性扣减操作。 - 布隆过滤器:快速判断商品是否参与秒杀,减少无效穿透到DB。
🗃️ 数据库持久化保障
- 读写分离架构:主库写操作,从库承担读压力。
- 乐观锁更新:通过版本号控制并发修改冲突。
- 事务补偿机制:若消息处理失败,定时重试直至成功。
三、关键技术实现细节
1. 库存预热策略(关键步骤)
@Service
public class SeckillPreloadService {
@Autowired private RedisTemplate<String, Object> redisTemplate; // 操作Redis的工具类
@Autowired private GoodsMapper goodsMapper; // MyBatis映射器查询数据库
/**
* 秒杀前预加载商品库存到Redis
* @param seckillId 秒杀场次ID
*/
public void preloadStock(Long seckillId) {
// 从数据库获取原始数据
SeckillGoods goods = goodsMapper.selectById(seckillId); // 根据ID查询商品详情
if (goods == null) throw new BusinessException("商品不存在"); // 防御空指针异常
// 构建Redis键名规范(业务语义明确)
String stockKey = "seckill:stock:" + seckillId; // 库存余量存储位置
String goodsKey = "seckill:goods:" + seckillId; // 完整商品信息缓存区
// 设置过期时间防止数据陈旧(根据活动时长动态配置)
redisTemplate.opsForValue().set(stockKey, goods.getStockCount()); // 仅存数字类型值节省空间
redisTemplate.opsForValue().set(goodsKey, goods, 1, TimeUnit.HOURS); // TTL设置为活动持续时间+缓冲期
}
}
设计要点:
- 独立存储两份数据:①纯数字型库存字段用于高频读写;②完整对象用于展示层渲染。
- TTL设置比实际活动结束多一定冗余窗口,避免自然过期导致脏数据。
2. 分布式限流方案(滑动窗口算法)
@Component
public class RedisLimiter {
@Autowired private StringRedisTemplate stringRedisTemplate; // 适合字符串操作的模板类
private DefaultRedisScript<Long> limitScript; // Lua脚本执行载体
@PostConstruct
public void init() {
// 加载自定义Lua逻辑到JVM内存中预编译
limitScript = new DefaultRedisScript<>();
limitScript.setScriptText(limitLuaScript()); // 注入脚本内容
limitScript.setResultType(Long.class); // 声明返回值类型便于转换
}
/**
* 检查当前请求是否允许通过限流器
* @param key 流量标识键(如用户ID哈希值)
* @param max 窗口内最大允许通过数量
* @param period 统计周期时长(毫秒)
* @return true=放行 false=拒绝
*/
public boolean limit(String key, int max, int period) {
List<String> keys = Collections.singletonList(key); // ZSET类型的有序集合唯一标识符
Long count = stringRedisTemplate.execute(limitScript, keys, String.valueOf(max), String.valueOf(period));
return count != null && count <= max; // 当前计数未超标则允许通行
}
/**
* Lua脚本实现滑动窗口计数器(核心算法)
*/
private String limitLuaScript() {
return "local key = KEYS[1]\n" + // 参数传递方式固定写法
"local max = tonumber(ARGV[1])\n" + // 第一个参数转成数字型阈值
"local period = tonumber(ARGV[2])\n" + // 第二个参数作为时间窗口长度
"local now = tonumber(redis.call('time')[1])\n" + // Unix时间戳(秒级精度足够)
"local windowStart = now - period\n" + // 计算窗口左边界起始点
"\n" + // 新行分隔符提高可读性
"-- 清理过期的历史记录\n" + // 注释说明代码意图
"redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)\n" + // 删除所有小于等于windowStart的成员
"-- 获取当前窗口内的请求总数\n" + // 再次注释增强维护性
"local count = redis.call('ZCARD', key)\n" + // ZCARD命令返回集合元素个数
"\n" + // 逻辑分支判断入口
"if count < max then\n" + // 如果还有剩余配额额度
" redis.call('ZADD', key, now, now .. ':' .. math.random())\n" + // 添加带随机后缀的时间戳作为唯一标识
" redis.call('EXPIRE', key, period)\n" + // 确保整个键自动过期释放资源
" return count + 1\n" + // 返回新的累计值用于决策依据
"end\n" + // 结束条件分支块
"return count"; // 超出限制时直接返回当前计数值
}
}
优势分析:
- 相比固定窗口算法,滑动窗口能更精准地控制真实时间内的请求密度。
- Lua脚本在Redis侧单线程执行,避免网络延迟导致的竞态条件。
3. 库存扣减与订单创建流程
@Service
public class SeckillService {
@Autowired private RedisTemplate<String, Object> redisTemplate; // 主力操作通道
@Autowired private StringRedisTemplate stringRedisTemplate; // 辅助工具类
@Autowired private AmqpTemplate amqpTemplate; // RabbitMQ生产者客户端
@Autowired private RedisLimiter redisLimiter; // 前置限流组件依赖注入
/**
* 执行秒杀核心逻辑(事务性保证局部一致性)
* @param userId 买家身份标识
* @param seckillId 目标商品编码
* @return 处理结果描述对象
*/
@Transactional(rollbackFor = Exception.class) // Spring声明式事务管理注解
public Result<OrderVO> executeSeckill(Long userId, Long seckillId) {
// 第一阶段:校验资格并尝试锁定资源
String stockKey = "seckill:stock:" + seckillId; // 与预热阶段保持一致的命名规则
String orderToken = generateOrderToken(); // 生成全局唯一令牌防重放攻击
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(orderToken, userId); // SETNX实现排他锁
if (!locked) throw new ConcurrentModificationException("请勿重复提交订单"); // 悲观锁失效时的快速失败机制
try {
// 第二阶段:原子化修改库存余额
Long remaining = redisTemplate.opsForValue().decrement(stockKey); // INCRBY命令原子递减
if (remaining < 0) throw new InsufficientStockException("库存已售罄"); // 负值触发异常回滚事务
// 第三阶段:发送异步消息给下游处理系统
OrderMessage message = new OrderMessage(userId, seckillId, orderToken); // 构造消息体对象
amqpTemplate.convertAndSend("order.queue", message); // 放入延迟队列等待消费
return Result.success(convertToVO(message)); // 立即响应前端成功状态码+虚拟订单号
} finally {
// 第四阶段:清理临时状态无论成败都要执行回收操作
stringRedisTemplate.delete(orderToken); // 释放锁资源防止死锁发生
}
}
}
事务边界说明:
@Transactional
仅包裹数据库层面的回滚逻辑,而Redis操作不受ACID约束,需通过补偿机制弥补最终一致性差异。- 采用“预扣库存+延迟确认”模式,既保证用户体验又规避了长事务持有的性能损耗。
四、数据库表结构示例
表名 | 字段说明 | 索引建议 |
---|---|---|
seckill_goods | id(PK), name, total_stock | PRIMARY KEY |
seckill_order | id(PK), user_id, goods_id, status | (user_id, create_time DESC) |
order_log | id, op_type, old_value, new_value | (create_time, operator_id) |
设计原则:
- 订单表增加状态机字段(如
UNPAID
,PAID
,CLOSED
),配合定时任务关闭超时未支付单据并释放库存。- binlog增量同步机制将MySQL变更实时推送到消息队列,供其他微服务订阅更新事件。
五、压测与调优建议
- 基准测试工具:使用JMeter模拟海量并发用户行为,重点关注TP99响应时间和错误率拐点。
- GC日志分析:通过
jstat
监控FullGC频率,调整堆内存分配策略减少Stop-The-World停顿。 - 线程池调优:根据CPU核心数设置合理的核心线程数和最大扩容倍数,避免上下文切换开销过大。
- 慢SQL治理:启用MySQL的慢查询日志定位低效语句,添加复合索引加速关联查询。
- 连接池配置:HikariCP的maxPoolSize建议设置为CPU核数×2+1,减少连接建立耗时。
六、典型异常处理场景
异常类型 | 应对策略 | 恢复措施 |
---|---|---|
库存不足 | 返回友好提示页引导用户刷新页面 | 前端实现自动重试机制 |
重复请求 | 根据请求ID去重 | 浏览器端跳转结果展示页 |
系统繁忙 | 降级返回静态缺货页面 | CDN缓存该页面减少后端压力 |
网络抖动 | 实现熔断机制快速失败 | Hystrix断路器模式自动隔离故障节点 |
总结
Java秒杀系统的库存设计需遵循“分层过滤、异步解耦、最终一致”的原则,结合Redis高性能特性与消息队列异步处理能力构建弹性架构。通过合理的限流策略、原子化操作和事务补偿机制,既能承受百万级并发压力,又能确保数据准确性。实际实施时建议分阶段灰度发布,并通过全链路监控持续优化系统表现。