前端管理界面顺手点几下,优惠券就发出去了,但到了自己动手做黑马点评项目时,才发现“添加秒杀券”这一步远没有想象中那么简单。很多同学跟我当初一样,数据库里只有零星几条测试数据,想验证秒杀下单、一人一单、库存扣减,却发现根本没有可用的秒杀券,卡在了起跑线上。这篇文章就围绕“黑马点评项目里如何用测试工具添加秒杀券”这个主题,把从表结构、接口设计到Postman/JMeter实操验证的整个链路拆开讲清楚,适合正在做黑马点评项目、准备项目面试或者想搞明白秒杀业务数据从哪来的读者。
我最初接触到这个需求时,第一反应是“加券不就是往表里插一条数据吗”,真做起来才发现,普通优惠券和秒杀券的数据模型不同、接口权限不同、Redis预热策略也不同,中间任何一个环节没对齐,测试工具发出去的请求就会变成一条错误日志。下面直接进入正题。
1. 秒杀券在项目里到底是什么:数据模型与业务链路
1.1 从普通优惠券到秒杀券:两张表的职责划分
黑马点评项目里,优惠券相关数据主要落在两张表:tb_voucher 和 tb_seckill_voucher。前者保存优惠券的通用信息,比如标题、副标题、金额、使用门槛、类型、有效期;后者专门保存秒杀相关的扩展字段,包括库存、开始时间、结束时间。
普通优惠券只在 tb_voucher 中有一条记录,类型字段 type 为 0;秒杀券则必须在两张表里各有一条记录,tb_voucher.type 为 1,同时 tb_seckill_voucher 中有一条关联记录,通过 voucher_id 字段指向主表主键。这种“主表存共性、从表存特性”的设计,是典型的垂直拆分思路,面试时被问到“为什么秒杀券要单独建表”就可以从这里展开:秒杀券多出的库存、开始/结束时间字段,如果全塞进优惠券主表,普通券的每条记录都得为这些字段预留空位,既浪费存储又让查询逻辑变复杂。
表结构对照如下:
| 字段 | tb_voucher | tb_seckill_voucher |
|---|---|---|
| 主键 | id | id |
| 关联字段 | 无 | voucher_id |
| 核心业务字段 | title, sub_title, price, pay_value, type, status | stock, begin_time, end_time |
| 通用字段 | create_time, update_time | create_time, update_time |
| 用途 | 定义优惠券本身 | 定义秒杀活动的库存与时间窗口 |
1.2 库存字段的初值与数据一致性
秒杀券的 stock 字段,代表这场秒杀活动可售的总量。用测试工具添加秒杀券时,这个值决定了后续并发压测的上限,如果你填 1,那 Redis 预热后库存就是 1,用 JMeter 模拟 100 个用户并发抢购时,最终只能有 1 个人成功,其余全是“库存不足”。
我建议在功能验证阶段把库存设置成 100 或者 1000,方便观察并发下的超卖保护效果。库存字段一旦写入,后续秒杀流程会先把它同步到 Redis,再通过 Lua 脚本扣减,数据库里的 stock 并不会在用户抢购瞬间被频繁更新,而是等异步或最终一致性的方式回写。理解这条链路,你就明白为什么添加秒杀券时,stock 必须是一个大于 0 的准确值,而不是随手填的 0 或负数。
1.3 添加秒杀券的权限链路:为什么不是全站管理员
黑马点评项目里有普通用户、店铺管理员两种角色。添加秒杀券的接口面向的是店铺管理员,也就是说,调用添加秒杀券接口前,必须完成管理员登录并拿到代表身份的 Token。普通用户登录后调用这个接口,会被拦截器拦下来,返回“未登录”或权限不足的错误。
这块常见的问题是:很多同学用测试工具直接调接口时,只配了 Content-Type: application/json,没有携带请求头中的 Token 字段,结果接口返回 401 或者业务码为 1 的报错。解决方式是在 Postman 里先调登录接口,把返回的 Token 复制到环境变量,然后在添加秒杀券请求的 Header 中动态引用。后面的实操部分我会给出具体配置。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 添加秒杀券的接口设计与代码落点
2.1 Controller 与 Service 的职责拆分
项目中的 VoucherController 提供了两个核心接口:一个用于保存普通优惠券,一个用于保存秒杀券。保存秒杀券的接口路径一般是 /voucher/seckill,方法签名大致如下:
java复制@PostMapping("/seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok();
}
Controller 只做了两件事:接收 JSON 请求体、调用 Service。真正的业务逻辑在 VoucherServiceImpl#addSeckillVoucher 中,它需要同时向 tb_voucher 和 tb_seckill_voucher 插入数据。这里有两个容易被忽略的细节:
第一,入参对象是 Voucher 实体,但它内部并不包含秒杀表所需的 beginTime、endTime、stock 等字段,那么这些字段从哪来?答案是通过继承或扩展字段。项目中的 Voucher 实体通常包含 stock、beginTime、endTime 这些临时字段(非表字段,用 @TableField(exist = false) 标记),或者单独做一个 SeckillVoucherDTO 接收参数。测试工具构造 JSON 时,需要把这些字段放在同一层 JSON 对象中,而不是嵌套的二级对象。
第二,Service 层会在插入主表后拿到主键 voucher.getId(),再以这个主键作为 tb_seckill_voucher.voucher_id 插入秒杀表。这两次插入操作必须放在同一个事务里,否则可能出现主表插入成功、从表插入失败的数据脏状态。项目里通过 @Transactional 注解保证原子性,但如果你自己在改造代码时把两次 insert 写到了不同方法且没有事务,就会埋下隐患。
2.2 Service 层隐藏的字段补全逻辑
看 Service 实现时,有几个字段需要在插入前手动补全,测试工具发请求时如果遗漏,入库数据就不完整:
java复制@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 1. 保存普通优惠券,type此时已由前端设置为1
save(voucher);
// 2. 保存秒杀券信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 3. 将秒杀券信息写入Redis
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(),
voucher.getStock().toString());
}
注意第 3 步,项目在添加秒杀券后,通常会把库存同步到 Redis,key 类似 seckill:stock:xxx。这一步是给秒杀阶段用的,因为真正的高并发扣减不会直接操作数据库,而是先操作 Redis。用测试工具添加券后,最好去 Redis 里检查一下这个 key 是否已经生成,如果没有生成,后续秒杀接口会直接判定“秒杀尚未开始”或“库存不足”。
2.3 接口参数校验的边界
黑马点评项目在参数校验上做得比较轻量,但你自己用测试工具测试时,还是要按业务规则来构造参数,否则会给自己制造不必要的排查成本。几个值得留意的字段:
type:必须传 1,表示这是一张秒杀券。传 0 会走普通券逻辑,秒杀表不会插入数据。stock:必须大于 0。填 0 的话秒杀接口永远返回“已售罄”。beginTime/endTime:必须保证 endTime 大于 beginTime,且 endTime 在当前时间之后。如果开始时间晚于当前时间,测试工具看到的返回虽然是成功,但用户端会显示“秒杀尚未开始”。payValue:秒杀价,一般要小于price(面值),否则用户感受不到“秒杀”的力度。
如果你收到的响应是 Result.ok(),但数据库里查不到记录,优先检查 JSON 字段名是否和实体字段名对应,比如 payValue 被写成了 pay_value,Spring MVC 在做反序列化时如果没开启下划线转驼峰配置,就会因为匹配不上而把所有字段置空,插入一条全是默认值的脏数据。
3. 用 Postman 完成添加秒杀券的完整操作
3.1 准备工作:启动项目并确认登录链路
在打开 Postman 之前,先把后端项目跑起来。黑马点评项目依赖 MySQL 和 Redis,启动前确认这两个中间件都处于可用状态。连接信息一般在 application.yaml 里配置,项目启动后控制台会打印 Spring Boot 启动日志,没有报错基本就算就绪。
然后用测试工具发送登录请求。黑马点评的登录接口一般是 /user/login 或 /user/code,流程是:手机号 + 验证码登录。项目为了开发方便,通常会在控制台直接输出验证码,比如 “验证码:123456”,你直接填进去即可。
登录成功后,响应里会携带 Token。这个 Token 需要在下一次请求中放到请求头里,Key 是 authorization,Value 是 Token 字符串。黑马点评项目的拦截器正是通过这个请求头判断用户是否已登录、以及当前用户是谁。
3.2 构造添加秒杀券请求
Postman 中新建一个 POST 请求,URL 填写 http://localhost:8080/voucher/seckill,Body 选择 raw + JSON,填入下面的内容:
json复制{
"title": "周年庆秒杀券",
"subTitle": "限时抢购,先到先得",
"price": 100,
"payValue": 10,
"type": 1,
"status": 1,
"stock": 100,
"beginTime": "2025-06-01T00:00:00",
"endTime": "2025-06-30T23:59:59"
}
请求头加上 authorization: {{token}} 和 Content-Type: application/json,点击 Send。如果一切正常,你会看到:
json复制{
"success": true
}
注意我这里 beginTime 和 endTime 用的是 ISO 8601 格式 yyyy-MM-dd'T'HH:mm:ss。黑马点评项目里的日期序列化配置如果是 Jackson 默认配置,那么这种格式是安全的。如果你用的是 yyyy-MM-dd HH:mm:ss 这种带空格的格式,必须检查项目里是否配置了全局的 ObjectMapper 日期格式,否则会报 JSON parse error,这也是新手高频踩坑点之一。
3.3 验证数据库与 Redis 中的数据
请求返回成功后,去数据库执行两条查询:
sql复制SELECT * FROM tb_voucher ORDER BY id DESC LIMIT 1;
SELECT * FROM tb_seckill_voucher ORDER BY id DESC LIMIT 1;
确认 tb_voucher 中有一条 type = 1 的优惠券记录,tb_seckill_voucher 中有一条 voucher_id 等于前一条记录主键、stock = 100 的秒杀记录。
接着去 Redis 查看库存 key:
bash复制redis-cli
KEYS seckill:stock:*
GET seckill:stock:xxx
返回的库存值应该也是 100。如果 Redis 中这个 key 不存在,说明 Service 层那段同步库存的代码没有执行,或者执行时报错被吞掉了。遇到这种情况,去后端控制台看有没有异常堆栈,优先排查 stringRedisTemplate 的注入是否成功、Redis 连接是否正常。
3.4 用环境变量管理 Token 与 Base URL
我强烈建议你在 Postman 里配置两个环境变量:base_url 和 token,而不是每次手写。左侧菜单选择 Environments,新建一个名为 “hmdp-local” 的环境,添加变量:
base_url初始值填http://localhost:8080token初始值随意,登录后通过脚本自动更新
请求 URL 可以写成 {{base_url}}/voucher/seckill,Token 在登录接口的 Tests 标签页里通过脚本提取并写入环境变量:
javascript复制const json = pm.response.json();
pm.environment.set("token", json.data.token);
这样换个人、换个测试环境,都不用改请求体,只要重新跑一遍登录脚本,后续所有接口都能直接使用最新的 Token,省去了手动复制的麻烦,也不容易把旧 Token 带到新请求里导致 401。
4. 添加完只是开始:秒杀链路中的 Redis 与库存校验
4.1 为什么秒杀券库存要预热到 Redis
数据库的磁盘 IO 和行锁机制,撑不住大规模用户同时抢购的场景。黑马点评的秒杀设计,把库存扣减前置到了 Redis,核心思路是:绝大多数用户请求在 Redis 这一层就被筛选掉了,真正能走到数据库下单的用户数量被压到很小。
添加秒杀券后,Service 层把 stock 写入 Redis,key 是 seckill:stock:{voucherId},String 类型保存剩余库存。后续秒杀请求进来时,先拼接这个 key,用 Lua 脚本做两步操作:检查 Redis 里的库存是否大于 0,大于则扣减并返回 1,否则返回 0。整个过程是原子的,不存在并发环境下的超卖问题。
这里有个容易误解的点:Lua 脚本扣减的是 Redis 中的库存,不是数据库中的 tb_seckill_voucher.stock。数据库库存的扣减发生在用户下单成功后,由 Service 层通过 update ... set stock = stock - 1 where id = ? and stock > 0 这样的 SQL 完成,靠数据库本身的乐观锁或条件更新来兜底。
4.2 用户下单的完整时序
添加秒杀券完成、Redis 预热成功后,用户在手机端点击秒杀,后端经历的大致流程是:
- 校验用户是否登录。
- 根据
voucherId查询秒杀券信息,判断当前时间是否在秒杀窗口内。未开始或已结束,直接返回错误。 - 判断用户是否已经下过单,实现一人一单。这里通常会查询订单表,或用 Redis 里的 Set 记录已购买用户ID。
- 执行 Lua 脚本扣减 Redis 库存。
- 扣减成功后,创建订单,保存到
tb_voucher_order。 - 异步或同步更新数据库库存。
用测试工具验证秒杀时,你可以不开前端页面,直接用 Postman 调 /voucher-order/seckill/{voucherId} 这个接口,先看返回结果,再去数据库和 Redis 验证数据变化。比如发起一次请求后,tb_voucher_order 多了一条订单记录,Redis 库存从 100 变成 99,说明整个链路是通的。
4.3 超卖与一人一单的实测表现
添加秒杀券时如果把库存设为 100,然后用 JMeter 开 200 个线程并发请求秒杀接口,你会观察到几种典型结果:
- 200 个请求全部返回,但真正下单成功的只有约 100 个,其余返回“库存不足”。
- 如果你的项目没实现一人一单,同一个用户可能生成多条订单;实现了一人一单,同一个用户第二次点击会被拦截,返回“不能重复下单”。
这块我在自己测试时遇到过一个问题:并发模拟用的手机号都是同一个,结果 200 个请求全部返回“请勿重复下单”或业务码提示已购买,库存根本没被扣完。原因就是项目默认限制同一个用户只能买一件。想做并发压测,必须在 JMeter 的 CSV 数据文件里准备多个手机号,配合登录获取各自 Token,才能模拟真实的多用户抢购场景。
4.4 异步下单与消息队列的补充说明
黑马点评的秒杀引入了异步下单,也就是用户点击秒杀后,如果 Redis 扣减成功,不直接在请求线程里写数据库,而是把订单信息丢到阻塞队列或消息队列,由独立线程消费、创建数据库订单。好处是接口响应变快,用户体验好,但代价是用户收到“下单成功”时,数据库订单可能还没写入,需要靠后续轮询或前端刷新确认。
自己动手做项目时,如果发现测试工具请求秒杀接口返回成功,但数据库订单表迟迟没有数据,大概率是异步线程出了问题。排查顺序:先看阻塞队列消费者有没有启动,再看消费者线程有没有异常日志,最后看事务提交是否正常。用测试工具做验证时,我会特意在秒杀成功返回后等一两秒再查数据库,而不是立刻去查,避免误判。
5. 测试工具实操中的高频问题与排查思路
5.1 日期序列化格式错误:时间偏移与解析失败
这是添加秒杀券接口里最常见的问题。Postman 发送的 JSON 中,beginTime 写成 "2025-06-01 00:00:00",如果项目没有配置 Jackson 的日期格式,Spring 会抛出类似 Cannot deserialize value of type java.time.LocalDateTime from String 的异常。
排查链路很清晰:先看 Postman 响应体是不是 500,再看后端控制台报错信息是否指向 Jackson 反序列化,最后检查项目里是否有自定义 ObjectMapper 或 spring.jackson.date-format 配置。黑马点评的默认配置一般能处理 ISO 格式,所以最省事的方案是提交前把时间字段统一改成 "2025-06-01T00:00:00" 这种格式。如果你确实需要用空格格式,就得自己在配置类里注册 LocalDateTime 的序列化器,用 DateTimeFormatter 指定 pattern。
5.2 登录拦截器放行路径问题
黑马点评项目里有拦截器或 Spring 拦截器配置,用于拦截需要登录的接口。如果拦截器配置里没有放行 /voucher/seckill,那么即使你登录成功并携带了 Token,请求依然会被拦截,返回 401。
排查方式是在拦截器的 preHandle 方法里打日志,或者直接看拦截器配置中的 excludePathPatterns。我曾遇到一种情况:添加秒杀券接口偶尔能通、偶尔 401,最后发现是请求头里的 Token 因为环境变量过期被覆盖成了空字符串,Postman 实际发出的 Header 里 authorization 没有值。解决办法是把请求发送前打开发送的 Headers 面板,肉眼确认 Token 是否真实存在。
5.3 数据库秒杀券字段缺失时的表现
如果你用的黑马点评 SQL 脚本版本比较老,或者是从别处拷来的精简库,tb_seckill_voucher 表可能缺少某些字段,比如 create_time、update_time。这时候测试工具调用接口会报 SQL 异常,提示 Unknown column。
我的建议是直接用项目自带的 hmdp.sql 初始化数据库,不要自己手建表。版本不一致时,优先对比表结构,特别是 tb_voucher 的 type 字段、tb_seckill_voucher 的 stock 字段,这两个是最容易出问题的点。
5.4 并发压测时库存数据不一致
用 JMeter 压测秒杀后,去 Redis 查库存为 0,但数据库 tb_seckill_voucher.stock 还有剩余,甚至出现负数,这是异步下单场景下常见的现象。数据库库存的扣减可能因为事务未提交、消费者线程崩溃等原因没有完全跟上 Redis 的扣减速度。
定位思路分三步:先看 Redis key 是否被扣到 0,再看数据库 stock 当前值,最后看订单表数量是否等于初始库存减去当前库存。如果订单表数量对得上,说明只是库存回写延迟,等待一段时间会恢复;如果对不上,就要检查订单创建的 SQL 里 stock > 0 条件是否生效。
5.5 用 JMeter 做简单并发验证的方法
推荐一个最小可用的 JMeter 压测方案:
- 创建线程组,线程数 100,Ramp-Up 1 秒,循环 1 次。
- 添加 HTTP 请求默认值,填写
base_url。 - 添加 HTTP Header 管理器,配置
authorization引用参数${token}。 - 添加 CSV 数据文件,存放 100 个测试手机号,每个线程读取一个不同的手机号和对应的 Token。
- 添加 HTTP 请求,访问
/voucher-order/seckill/{voucherId}。 - 添加查看结果树和聚合报告,观察吞吐量、错误率、响应时间。
跑完后,重点不是看响应时间,而是看数据库订单表里有多少条记录、Redis 里库存剩余多少。如果订单记录数大于初始库存,说明有严重超卖,需要检查 Lua 脚本或 SQL 的原子性;如果订单数小于库存,说明有用户抢购失败,属正常现象。
6. 我的实操习惯与几个建议
做黑马点评项目这段时间,我慢慢养成了一套固定的测试流程:先通过测试工具添加秒杀券,确认数据库和 Redis 数据齐了,再开秒杀接口做功能验证,最后才上 JMeter 压测。顺序不能乱,否则一旦并发结果不符合预期,你很难分清是数据没配好,还是业务逻辑有问题。
我特别建议在测试工具里把添加秒杀券的请求保存成一个独立 Collection,并做好环境变量管理。这样一个请求模板可以反复使用,切换不同店铺、不同日期、不同库存,只需要改几个字段,不用重新构造整个请求体。做项目面试复盘时,也能讲清楚“你是如何造数据、如何验证秒杀逻辑、如何排查数据不一致”的完整链路,这些实际操作的细节远比背面试题更有说服力。
如果后续你打算扩展黑马点评项目,可以在添加秒杀券后加一个主动刷新 Redis 缓存的操作,或者在秒杀开始前用定时任务预热热点数据。这些优化都能在面试时成为亮点,但前提是先把现在这条链路跑通,知道每一步数据的来龙去脉,扩展起来才不至于失控。
