管理主页分类

拖动分类调整顺序,并选择是否在主页显示。设置仅保存在当前浏览器。

  • 微服务课件 31
  • Java 基础 28
  • Java Web 开发 25
  • 多来买 18
  • LeetCode 题解 8
  • 开发工具 8
  • 网络工具 2
  • 黑马头条 2
  • Java 记忆恢复 1

多来买:秒杀预热、下单码与防重实战

2299 字
11 分钟
多来买:秒杀预热、下单码与防重实战

多来买:秒杀预热、下单码与防重实战#

本篇范围:活动数据模型、Redis 预热、列表与详情、下单码、交易页校验、本地快速失败和用户防重
事实来源:当前 PromoControllerPromoServiceImplSeckillGoodsLocalCacheHelper 与 Redis 常量
验证口径:代码已确认;未启动应用、未验证定时预热、Redis 数据或接口并发效果

本篇聚焦消息发送之前的秒杀入口。它解释活动数据怎样进入 Redis、下单码实际保护哪一段请求、本地售罄状态为什么只能当旁路过滤,以及用户 Set 表达的是“已经提交过”还是“已经下单成功”。

本篇主链路#

sequenceDiagram participant Admin as "预热入口" participant Promo as "promo-service" participant DB as "MySQL" participant Redis as "Redis" participant Local as "JVM 本地状态" participant Client as "客户端" Admin->>Promo: importIntoCache Promo->>DB: 查询当天活动商品 Promo->>Redis: 写商品 Hash 与库存 Hash Promo->>Local: 初始化售罄状态 Client->>Promo: 查询列表/详情 Promo->>Redis: 读取活动数据 Client->>Promo: 获取下单码 Promo-->>Client: MD5(userId + skuId) Client->>Promo: 进入交易页 Promo->>Promo: 校验时间、下单码和本地状态 Client->>Promo: 提交抢购 Promo->>Redis: RSet.tryAdd(userId)

阅读目标#

  • 识别 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);

条件是:

  1. 审核通过;
  2. 数据库库存大于 0;
  3. 开始日期为当天。

预热查询没有单独限制当前时刻必须已到开始时间,因为它的目标是活动前准备,而不是判断用户此刻能否抢购。

初始化进程内库存状态#

代码路径: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 后面用 HGETHINCRBY 直接操作 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());
}
@Override
public SeckillGoodsDTO
getSeckillGoodsDTO(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 = skuId

tryAdd#

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,且它表示“进入提交链路”,不能当作订单成功凭证。

关键源码导航#

阅读顺序文件关键方法或类型
1duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/controller/PromoController.java列表、详情、下单码、交易页与提交入口
2duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/service/impl/PromoServiceImpl.javaimportIntoCachegetTradeData
3duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/model/SeckillGoods.java活动实体
4duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/util/LocalCacheHelper.java本地售罄状态
5duolaimall-common/common-service/src/main/java/com/cskaoyan/mall/common/constant/RedisConst.javaPROMO_* key

4 条短复习点#

  1. 预热把高频读取从 MySQL 前移到 Redis。
  2. 本地状态只做售罄旁路过滤,不能代替 Redis 库存。
  3. 下单码只保护当前交易页入口,并非完整防刷令牌。
  4. 用户 Set 防的是重复提交,不代表订单已经创建。

本章总结#

活动预热、本地售罄旁路、下单码和用户 Set 分别减少回源、快速拒绝和重复提交,但它们表达的是不同状态,不能共同推导为秒杀订单已经成功。

下一篇:Lua 库存与 RocketMQ 事务消息实战

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

多来买:秒杀预热、下单码与防重实战
https://firefly-mu-weld.vercel.app/posts/duolaimai-seckill-preheat-order-code/
作者
Daisy
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录