多来买:秒杀预热、下单码与防重实战
多来买:秒杀预热、下单码与防重实战
本篇范围:活动数据模型、Redis 预热、列表与详情、下单码、交易页校验、本地快速失败和用户防重
事实来源:当前PromoController、PromoServiceImpl、SeckillGoods、LocalCacheHelper与 Redis 常量
验证口径:代码已确认;未启动应用、未验证定时预热、Redis 数据或接口并发效果
本篇聚焦消息发送之前的秒杀入口。它解释活动数据怎样进入 Redis、下单码实际保护哪一段请求、本地售罄状态为什么只能当旁路过滤,以及用户 Set 表达的是“已经提交过”还是“已经下单成功”。
本篇主链路
阅读目标
- 识别
SeckillGoods中活动时间、活动价和库存字段。 - 说明 Redis 商品 Hash、库存 Hash、用户 Set 与订单结果 Map 的职责差异。
- 说明本地状态位能减少压力,但不是多实例共享的库存事实。
- 说明当前下单码是确定性 MD5,并且只在交易页校验。
- 说明用户 Set 原子防重的语义与生命周期边界。
秒杀商品与 Redis 数据结构
SeckillGoods
代码路径:duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/model/SeckillGoods.java
@Data@TableName("seckill_goods")public class SeckillGoods extends BaseEntity {
private Long skuId; private String skuName; private BigDecimal price;
@TableField("cost_price") private BigDecimal costPrice;
private String status;
@TableField("start_time") private Date startTime;
@TableField("end_time") private Date endTime;
private Integer num;
@TableField("stock_count") private Integer stockCount;}price 是普通价格,costPrice 在交易页作为秒杀价使用;stockCount 是数据库中的活动库存基线。实时秒杀扣减主要发生在 Redis Hash。
当前 Redis Key 形态
代码路径:duolaimall-common/common-service/src/main/java/com/cskaoyan/mall/common/constant/RedisConst.java
promo:seckillgoods Hash<skuId, SeckillGoods>
promo:goods:stock Hash<String skuId, String remainingStock>
promo:user:orderflag:{userId} Set<skuId>
promo:transaction:{transactionId} String "success" | "fail"
promo:resume:message:{msgId} String "consume"
promo:orders Hash<"userId:skuId", orderId>这些结构分别表达:
- 活动商品事实;
- 剩余 Redis 库存;
- 用户是否已经提交过该 SKU;
- 事务消息本地库存结果;
- MQ 消息是否已经消费;
- 异步订单是否已经生成。
除活动清理使用 promo:* 批量删除外,主链没有给这些标记设置 TTL。
第一步:预热当天活动
代码路径:duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/service/impl/PromoServiceImpl.java
关键方法:importIntoCache
查询当天可用活动商品
QueryWrapper<SeckillGoods> queryWrapper = new QueryWrapper<>();
queryWrapper.eq( "status", SeckillGoodsStatus.CHECKED_PASS.name());queryWrapper.gt("stock_count", 0);queryWrapper.eq( "DATE_FORMAT(start_time,'%Y-%m-%d')", DateUtil.formatDate(new Date()));
List<SeckillGoods> goodsList = seckillGoodsMapper.selectList(queryWrapper);条件是:
- 审核通过;
- 数据库库存大于 0;
- 开始日期为当天。
预热查询没有单独限制当前时刻必须已到开始时间,因为它的目标是活动前准备,而不是判断用户此刻能否抢购。
初始化进程内库存状态
代码路径:duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/util/LocalCacheHelper.java
private static final Map<String, Object> cacheMap = new ConcurrentHashMap<>();
public static void put( String key, Object cacheObject) { cacheMap.put(key, cacheObject);}
public static Object get(String key) { return cacheMap.get(key);}预热时:
for (SeckillGoods goods : goodsList) { LocalCacheHelper.put( goods.getSkuId().toString(), LocalStockStatus.HAS_STOCK.getNum());}状态位只表示“当前节点认为仍有库存”。它是快速过滤,不是库存事实。多实例部署时,每个 JVM 都有自己的 Map,状态不会自动广播。
缓存商品和库存
RMap<Long, SeckillGoods> goodsMap = redissonClient.getMap( RedisConst.PROMO_SECKILL_GOODS);
for (SeckillGoods goods : goodsList) { if (!goodsMap.containsKey( goods.getSkuId())) { goodsMap.put( goods.getSkuId(), goods); }}库存使用 StringCodec:
RMap<String, String> stockMap = redissonClient.getMap( RedisConst.PROMO_SECKILL_GOODS_STOCK, new StringCodec());
for (SeckillGoods goods : goodsList) { String skuId = goods.getSkuId().toString();
if (!stockMap.containsKey(skuId)) { stockMap.put( skuId, goods.getStockCount().toString()); }}指定字符串序列化很关键。Lua 后面用 HGET、HINCRBY 直接操作 Hash field 和数字字符串;若 field/value 使用对象二进制序列化,脚本按普通字符串就无法正确命中。
containsKey + put 是两步操作,预热并发触发时并非一个原子“只写一次”。当前注释表明它主要为了本地测试时不覆盖已有缓存。
第二步:从 Redis 查询列表和详情
代码路径:duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/service/impl/PromoServiceImpl.java
private List<SeckillGoods>getCurrentDayGoodsList() { RMap<Long, SeckillGoods> map = redissonClient.getMap( RedisConst.PROMO_SECKILL_GOODS);
return map.values().stream() .collect(Collectors.toList());}
@Overridepublic SeckillGoodsDTOgetSeckillGoodsDTO(Long skuId) { RMap<Long, SeckillGoods> map = redissonClient.getMap( RedisConst.PROMO_SECKILL_GOODS);
SeckillGoods goods = map.get(skuId); return seckillGoodsConverter .convertSeckillGoodsToDTO(goods);}列表和详情的当前主路径都读 Redis,不再每次访问数据库。若预热未执行或 Redis 数据缺失,列表为空或详情转换空对象的具体表现取决于转换器,当前 Controller 没有主动回源 MySQL。
第三步:生成下单码
代码路径:duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/controller/PromoController.java
关键方法:getSekillSkuIdStr
先判断活动时间
String userId = AuthContext.getUserId(request);
SeckillGoodsDTO goods = promoService.getSeckillGoodsDTO(skuId);
Date now = new Date();
if (now.getTime() >= goods.getStartTime().getTime() && now.getTime() <= goods.getEndTime().getTime()) {
String code = MD5.encrypt(userId + skuId);
return Result.ok(code);}
return Result.build( null, SeckillCodeEnum.SECKILL_ILLEGAL);只有活动时间内才发码。下单码是确定性的:
orderCode = MD5(userId + skuId)它没有随机数、服务端秘密、Redis 状态或独立过期时间。安全性主要依赖客户端无法伪造可信 userId,以及后端后续重新计算。
下单码保护的范围
当前下单码只在“进入秒杀交易页”时校验。提交订单接口的请求体和参数中没有 skuIdStr,所以最终提交并没有再次验证下单码。
因此不能说“有下单码才能完成最终提交”;准确表述是“下单码保护了当前交易页入口,但提交接口还缺少二次校验”。
第四步:交易页校验下单码和本地状态
代码路径:duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/controller/PromoController.java
关键方法:seckillTrade
String userId = AuthContext.getUserId(request);
String currentCode = MD5.encrypt(userId + skuId);
if (!currentCode.equals(skuIdStr)) { return Result.build( null, SeckillCodeEnum.SECKILL_ILLEGAL);}
Object stockFlag = LocalCacheHelper.get( skuId.toString());
if (!LocalStockStatus.HAS_STOCK .getNum() .equals(stockFlag)) { return Result.build( null, SeckillCodeEnum.SECKILL_FINISH);}通过后读取 Redis 活动商品并组装确认页:
代码路径:duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/service/impl/PromoServiceImpl.java
关键方法:getTradeData
List<UserAddressDTO> addresses = userApiClient .findUserAddressListByUserId( userId);
OrderDetailDTO detail = seckillGoodsConverter .secondKillGoodsToOrderDetailDTO( seckillGoods, 1);
OrderTradeDTO trade = new OrderTradeDTO();trade.setUserAddressList(addresses);trade.setDetailArrayList( Arrays.asList(detail));trade.setTotalNum(1);trade.setTotalAmount( seckillGoods.getCostPrice());秒杀交易页固定一件商品,总金额使用 Redis 活动商品的 costPrice。
当前交易页时间边界
交易页本身不再检查活动开始和结束时间,只检查下单码和本地状态。下单码是确定性值,也没有服务端过期记录;如果用户保存了旧码,是否还能进入取决于当前 Redis 数据和本地状态。
第五步:提交接口先做本地快速失败
代码路径:duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/controller/PromoController.java
关键方法:submitSeckillOrder
String userId = AuthContext.getUserId(request);
Long skuId = orderInfoParam .getOrderDetailList() .get(0) .getSkuId();
Object stockFlag = LocalCacheHelper.get( skuId.toString());
if (!LocalStockStatus.HAS_STOCK .getNum() .equals(stockFlag)) { return Result.build( null, SeckillCodeEnum.SECKILL_FINISH);}这里同样直接读取第一条明细。空明细会抛异常;多条明细也只取第一条 SKU 参与秒杀判断。
提交接口没有重新校验:
- 下单码;
- 活动起止时间;
- 请求中的秒杀价格;
- 请求明细是否与 Redis 活动商品完全一致。
虽然 Service 会按 SKU 查询 Redis 商品是否存在,但不会用 Redis 商品重新覆盖整个订单明细和价格。
第六步:Redis Set 防止同用户重复抢购
数据形态
key = promo:user:orderflag:{userId}type = Set<Long>member = skuIdtryAdd
String key = RedisConst.PROMO_USER_ORDERED_FLAG + userId;
RSet<Long> set = redissonClient.getSet(key);
boolean firstAttempt = set.tryAdd(skuId);
if (!firstAttempt) { return Result.build( null, SeckillCodeEnum .SECKILL_DUPLICATE_TRADE);}
orderInfoParam.setUserId( Long.valueOf(userId));Set 添加是原子的:
- member 不存在:加入并返回
true; - member 已存在:不重复写并返回
false。
这个标记表达“用户已经进入过提交链路”,不等于“订单已经成功创建”。发送消息明确失败时当前代码会删除它,但其他失败和未知结果分支不一定删除。
标记生命周期
RSet 没有设置 TTL。活动清理接口会批量删除 promo:*,但仓库没有看到本次已验证的定时调度。因此标记何时清除取决于外部是否调用清理入口。
本篇限制与风险收束
- 当前下单码由
MD5(userId + skuId)确定生成,没有随机性、TTL 和一次性消费。 - 最终提交接口没有再次携带和校验下单码,也没有完整复核活动时间。
- 提交参数中的价格等业务字段没有完全从 Redis 活动数据重建。
- JVM 本地售罄状态多实例不共享,只能做快速失败,最终事实仍由 Redis Lua 判断。
- 用户防重 Set 没有 TTL,且它表示“进入提交链路”,不能当作订单成功凭证。
关键源码导航
| 阅读顺序 | 文件 | 关键方法或类型 |
|---|---|---|
| 1 | duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/controller/PromoController.java | 列表、详情、下单码、交易页与提交入口 |
| 2 | duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/service/impl/PromoServiceImpl.java | importIntoCache、getTradeData |
| 3 | duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/model/SeckillGoods.java | 活动实体 |
| 4 | duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/util/LocalCacheHelper.java | 本地售罄状态 |
| 5 | duolaimall-common/common-service/src/main/java/com/cskaoyan/mall/common/constant/RedisConst.java | PROMO_* key |
4 条短复习点
- 预热把高频读取从 MySQL 前移到 Redis。
- 本地状态只做售罄旁路过滤,不能代替 Redis 库存。
- 下单码只保护当前交易页入口,并非完整防刷令牌。
- 用户 Set 防的是重复提交,不代表订单已经创建。
本章总结
活动预热、本地售罄旁路、下单码和用户 Set 分别减少回源、快速拒绝和重复提交,但它们表达的是不同状态,不能共同推导为秒杀订单已经成功。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!