黑马点评-优惠券秒杀-02_voucher_table_design
黑马点评优惠券秒杀二:为什么有了优惠券表,还要再拆秒杀券表?
本文继续整理黑马点评 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")
public class Voucher implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long shopId;
private String title;
private String subTitle;
private String rules;
private Long payValue;
private Long actualValue;
private Integer type;
private Integer status;
}
它主要描述:
这张券属于哪个店铺
这张券叫什么
使用规则是什么
支付金额是多少
抵扣金额是多少
券的类型和状态是什么
这些都是一张优惠券的基础信息。
所以 tb_voucher 可以理解为:
优惠券身份证表
不管是普通券还是秒杀券,它首先都是一张优惠券。
4. Voucher 里为什么也有 stock、beginTime、endTime
这是一个很容易卡住的点。
Voucher 类中确实有:
@TableField(exist = false)
private Integer stock;
@TableField(exist = false)
private LocalDateTime beginTime;
@TableField(exist = false)
private LocalDateTime endTime;
很多人看到这里会误以为:
tb_voucher 表里也有库存、开始时间、结束时间。
其实不是。
关键在这个注解:
@TableField(exist = false)
它的意思是:
这个字段不是数据库表中的真实列。
那为什么还要放在 Voucher 类里?
因为新增秒杀券时,前端会一次性提交两类数据:
1. 优惠券基础信息:标题、规则、金额、店铺 id
2. 秒杀信息:库存、开始时间、结束时间
为了接收这个请求,项目把秒杀字段临时挂在 Voucher 对象上。
所以这里要分清:
Java 对象里有字段
不等于
数据库表里一定有这个列
5. tb_seckill_voucher:秒杀扩展表
SeckillVoucher 对应的是秒杀优惠券表:
@TableName("tb_seckill_voucher")
public class SeckillVoucher implements Serializable {
@TableId(value = "voucher_id", type = IdType.INPUT)
private Long voucherId;
private Integer stock;
private LocalDateTime beginTime;
private LocalDateTime endTime;
}
这张表不是“另一张优惠券表”。
它更准确地说是:
秒杀扩展信息表
它只保存秒杀业务需要的字段:
voucher_id:关联哪一张优惠券
stock:秒杀库存
begin_time:秒杀开始时间
end_time:秒杀结束时间
这里最重要的是:
@TableId(value = "voucher_id", type = IdType.INPUT)
private Long voucherId;
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")
public class VoucherOrder implements Serializable {
@TableId(value = "id", type = IdType.INPUT)
private Long id;
private Long userId;
private Long voucherId;
private Integer payType;
private Integer status;
}
它记录的是:
谁买了哪张券
比如:
id = 订单 ID
user_id = 用户 ID
voucher_id = 优惠券 ID
后面的一人一单判断,本质上就是查这张表:
select count(*)
from tb_voucher_order
where user_id = ?
and voucher_id = ?
如果结果大于 0,就说明这个用户已经买过这张券。
7. 新增普通券:只写 tb_voucher
新增普通券的接口类似:
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
voucherService.save(voucher);
return Result.ok(voucher.getId());
}
普通券只需要保存基础信息。
所以只执行:
voucherService.save(voucher);
这句是 MyBatis-Plus 的通用保存方法,可以粗略理解为:
insert into tb_voucher (...)
values (...)
8. 新增秒杀券:为什么要写两张表
新增秒杀券的代码:
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 保存秒杀库存到 Redis
stringRedisTemplate.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)
private Long id;
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}
这样第三章的数据模型就立住了。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)