news 2026/5/29 1:49:00

黑马点评-优惠券秒杀-02_voucher_table_design

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
黑马点评-优惠券秒杀-02_voucher_table_design

黑马点评优惠券秒杀二:为什么有了优惠券表,还要再拆秒杀券表?

本文继续整理黑马点评 Redis 实战篇第 3 章「优惠券秒杀」。

上一篇讲了全局唯一订单 ID。这一篇先不急着进入下单,而是把秒杀券的数据模型讲清楚。因为如果不先分清tb_vouchertb_seckill_vouchertb_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. 新增秒杀券的数据流转图

管理端提交秒杀券信息

VoucherController /voucher/seckill

addSeckillVoucher(voucher)

save(voucher) 写入 tb_voucher

数据库生成 voucher.id

创建 SeckillVoucher

setVoucherId(voucher.getId)

写入 tb_seckill_voucher

写入 Redis: seckill:stock:{id}


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}

这样第三章的数据模型就立住了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/29 1:41:12

where后面可以跟group by 么

不可以,WHERE 后面不能直接跟 GROUP BY,二者执行顺序、作用完全不同。一、执行顺序(核心规则)SQL 子句固定先后顺序: FROM → JOIN → WHERE → GROUP BY → HAVING → ORDER BY → LIMITWHERE:分组之前过滤…

作者头像 李华
网站建设 2026/5/29 1:34:51

OpenGL ES与Vulkan图形API调试与追踪技术详解

1. 图形API调试的核心需求解析在移动端和嵌入式图形开发中,OpenGL ES和Vulkan作为两大主流图形API,其调试过程往往让开发者感到棘手。不同于传统CPU调试可以单步跟踪,图形API的调用涉及GPU硬件流水线,常规调试器难以捕捉完整的调用…

作者头像 李华