黑马点评优惠券秒杀二:为什么有了优惠券表,还要再拆秒杀券表?
本文继续整理黑马点评 Redis 实战篇第 3 章「优惠券秒杀」。
上一篇讲了全局唯一订单 ID。这一篇先不急着进入下单,而是把秒杀券的数据模型讲清楚。因为如果不先分清
tb_voucher、tb_seckill_voucher、tb_voucher_order分别负责什么,后面看库存、一人一单、下单记录时会很容易混。
1. 本文解决什么问题
这篇主要解决几个学习时很容易冒出来的问题:
1. 普通券到底有没有库存? 2. 为什么有了 tb_voucher,还要有 tb_seckill_voucher? 3. Voucher 类里明明有 stock,为什么又说 tb_voucher 不存库存? 4. 新增秒杀券时,为什么要先 save(voucher),再保存 SeckillVoucher? 5. voucherId 能不能直接让前端传?先给结论:
tb_voucher存优惠券本体信息,tb_seckill_voucher存秒杀专属信息,tb_voucher_order存用户购买记录。秒杀券本质上是“优惠券本体 + 秒杀扩展信息”,所以新增秒杀券时需要先保存优惠券本体,再用生成出来的 voucherId 保存秒杀扩展。
2. 为什么要拆三张表
优惠券秒杀里至少有三类信息:
1. 优惠券本身是什么。 2. 这张券参与秒杀时有什么规则。 3. 哪个用户买了哪张券。这三类信息不是一回事。
如果强行塞进一张表,会变成:
普通券也带库存字段 普通券也带秒杀开始时间 普通券也带秒杀结束时间 订单记录也和券本体混在一起所以更合理的拆法是:
tb_voucher:优惠券本体 tb_seckill_voucher:秒杀扩展信息 tb_voucher_order:用户下单记录3. tb_voucher:优惠券本体表
Voucher实体对应的是:
@TableName("tb_voucher")publicclassVoucherimplementsSerializable{@TableId(value="id",type=IdType.AUTO)privateLongid;privateLongshopId;privateStringtitle;privateStringsubTitle;privateStringrules;privateLongpayValue;privateLongactualValue;privateIntegertype;privateIntegerstatus;}它主要描述:
这张券属于哪个店铺 这张券叫什么 使用规则是什么 支付金额是多少 抵扣金额是多少 券的类型和状态是什么这些都是一张优惠券的基础信息。
所以tb_voucher可以理解为:
优惠券身份证表不管是普通券还是秒杀券,它首先都是一张优惠券。
4. Voucher 里为什么也有 stock、beginTime、endTime
这是一个很容易卡住的点。
Voucher类中确实有:
@TableField(exist=false)privateIntegerstock;@TableField(exist=false)privateLocalDateTimebeginTime;@TableField(exist=false)privateLocalDateTimeendTime;很多人看到这里会误以为:
tb_voucher 表里也有库存、开始时间、结束时间。其实不是。
关键在这个注解:
@TableField(exist=false)它的意思是:
这个字段不是数据库表中的真实列。那为什么还要放在Voucher类里?
因为新增秒杀券时,前端会一次性提交两类数据:
1. 优惠券基础信息:标题、规则、金额、店铺 id 2. 秒杀信息:库存、开始时间、结束时间为了接收这个请求,项目把秒杀字段临时挂在Voucher对象上。
所以这里要分清:
Java 对象里有字段 不等于 数据库表里一定有这个列5. tb_seckill_voucher:秒杀扩展表
SeckillVoucher对应的是秒杀优惠券表:
@TableName("tb_seckill_voucher")publicclassSeckillVoucherimplementsSerializable{@TableId(value="voucher_id",type=IdType.INPUT)privateLongvoucherId;privateIntegerstock;privateLocalDateTimebeginTime;privateLocalDateTimeendTime;}这张表不是“另一张优惠券表”。
它更准确地说是:
秒杀扩展信息表它只保存秒杀业务需要的字段:
voucher_id:关联哪一张优惠券 stock:秒杀库存 begin_time:秒杀开始时间 end_time:秒杀结束时间这里最重要的是:
@TableId(value="voucher_id",type=IdType.INPUT)privateLongvoucherId;voucherId不是重新生成一张券的 ID,而是关联tb_voucher.id。
也就是说:
tb_voucher.id = 1 tb_seckill_voucher.voucher_id = 1表示:
id 为 1 的这张优惠券,参加了秒杀活动。6. tb_voucher_order:订单事实表
VoucherOrder对应订单表:
@TableName("tb_voucher_order")publicclassVoucherOrderimplementsSerializable{@TableId(value="id",type=IdType.INPUT)privateLongid;privateLonguserId;privateLongvoucherId;privateIntegerpayType;privateIntegerstatus;}它记录的是:
谁买了哪张券比如:
id = 订单 ID user_id = 用户 ID voucher_id = 优惠券 ID后面的一人一单判断,本质上就是查这张表:
selectcount(*)fromtb_voucher_orderwhereuser_id=?andvoucher_id=?如果结果大于 0,就说明这个用户已经买过这张券。
7. 新增普通券:只写 tb_voucher
新增普通券的接口类似:
@PostMappingpublicResultaddVoucher(@RequestBodyVouchervoucher){voucherService.save(voucher);returnResult.ok(voucher.getId());}普通券只需要保存基础信息。
所以只执行:
voucherService.save(voucher);这句是 MyBatis-Plus 的通用保存方法,可以粗略理解为:
insertintotb_voucher(...)values(...)8. 新增秒杀券:为什么要写两张表
新增秒杀券的代码:
@Override@TransactionalpublicvoidaddSeckillVoucher(Vouchervoucher){// 保存优惠券save(voucher);// 保存秒杀信息SeckillVoucherseckillVoucher=newSeckillVoucher();seckillVoucher.setVoucherId(voucher.getId());seckillVoucher.setStock(voucher.getStock());seckillVoucher.setBeginTime(voucher.getBeginTime());seckillVoucher.setEndTime(voucher.getEndTime());seckillVoucherService.save(seckillVoucher);// 保存秒杀库存到 RedisstringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());}这个方法做了三件事:
1. 保存优惠券本体到 tb_voucher。 2. 保存秒杀信息到 tb_seckill_voucher。 3. 把秒杀库存预热到 Redis。9. 为什么必须先 save(voucher)
这里最容易问:
voucherId 不是能前端传进来吗?技术上前端当然可以传。
但业务上不应该让前端决定新优惠券的主键 ID。
Voucher的主键是:
@TableId(value="id",type=IdType.AUTO)privateLongid;IdType.AUTO表示:
这个 ID 由数据库自增生成。所以新增秒杀券的正确顺序是:
1. 前端传来优惠券业务信息。 2. 后端先保存 tb_voucher。 3. 数据库生成真实 voucher.id。 4. 后端再用这个 id 写 tb_seckill_voucher.voucher_id。如果信任前端传来的voucherId,会有几个问题:
1. 前端可能传一个已经存在的 ID,导致主键冲突。 2. 前端传的 ID 不一定真实存在。 3. 两张表之间的关联关系会变得不可信。所以这里不是“前端不能传”,而是:
新增数据时,主键应该由后端和数据库产生,不能由外部请求随便决定。
10. 为什么新增秒杀券时还要写 Redis
最后这句:
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());它会写入:
seckill:stock:{voucherId} -> stock比如:
seckill:stock:1 -> 100这一步是为后面的秒杀优化做准备。
因为秒杀时如果每个请求都去 MySQL 查库存,数据库压力会很大。
后面会把资格判断前移到 Redis:
Redis 先判断库存和一人一单 通过后再异步下单所以新增秒杀券时,顺手把库存写入 Redis,是后续优化链路的一部分。
11. 新增秒杀券的数据流转图
12. 易错点
1. 普通券不是完全不能有数量概念
真实业务里普通券也可能有限量。
但在这个项目的建模里,库存和抢购时间主要属于秒杀业务,所以放在tb_seckill_voucher。
2.Voucher.stock不是tb_voucher.stock
因为它标了:
@TableField(exist=false)它只是 Java 对象中用于接收参数的字段。
3.tb_seckill_voucher不是新的券本体表
它只是给已有优惠券补充秒杀字段。
4. 前端传主键不可信
新增数据时,主键应该由数据库或后端 ID 生成器负责。
13. 面试怎么回答
如果面试官问:为什么优惠券和秒杀券要拆表?
可以回答:
因为优惠券基础信息和秒杀活动信息不是同一类数据。普通券只需要标题、规则、金额等基础字段,而秒杀券额外需要库存、开始时间、结束时间。把秒杀字段拆到
tb_seckill_voucher中,可以避免普通券携带无意义字段,也让秒杀业务扩展更清晰。
如果面试官问:新增秒杀券的流程是什么?
可以回答:
后端先保存优惠券基础信息到
tb_voucher,拿到数据库生成的优惠券 ID;然后创建SeckillVoucher,把这个 ID 作为voucher_id,再保存库存和秒杀时间到tb_seckill_voucher;最后把秒杀库存以seckill:stock:{voucherId}为 key 写入 Redis,为后续 Redis 秒杀资格判断做准备。
如果面试官问:为什么不能让前端传 voucherId?
可以回答:
新增优惠券时,主键 ID 应该由数据库或后端生成,不能信任前端传入。否则可能出现主键冲突、伪造关联或关联不存在数据的问题。正确做法是先保存券本体,拿到真实 ID,再写秒杀扩展表。
14. 总结
这一节的核心是把三张表分清:
tb_voucher:优惠券本体 tb_seckill_voucher:秒杀规则和库存 tb_voucher_order:用户购买记录新增普通券,只写tb_voucher。
新增秒杀券,要写:
tb_voucher tb_seckill_voucher Redis 秒杀库存理解完这一步,后面再看秒杀下单时,就能知道:
判断时间和库存要查 SeckillVoucher 保存订单要写 VoucherOrder 一人一单要查 VoucherOrder Redis 优化要基于 seckill:stock:{voucherId}这样第三章的数据模型就立住了。