☰
黑马点评秒杀压测:自动生成1000个token的完整方案与登录态原理
2026/10/8 8:56:27 网站建设 项目流程

做黑马点评的人,大概率都经历过这种尴尬时刻:教程一路推到p70,秒杀功能代码抄得一字不差,但你想把“一人一单”真正放到并发场景下验证时,发现整个项目就那么一两个测试账号,登录一次只能拿一个token。用一个token并发打一千次,系统当然能拦住重复下单,可你要验证的是“一千个不同用户同时抢同一张券”,没有一千个token,这个场景根本测不出来。我就是在这个节点上折腾出了整套“自动生成1000个token”的方案,跑通之后不但压测顺利,还把项目里token从生成、校验到滑动续期的机制彻底搞明白了,面试被问到登录态设计时也有了实打实的案例。这篇文章把这些东西完整整理出来,给正在跟课、或者项目做完想深挖一遍的Java学习者做个参考。

1. p70的秒杀压测卡点:为什么非得备好1000个token

1.1 一人一单的业务逻辑,注定了单个token测不出效果

黑马点评的秒杀功能落到代码层面,核心就是两件事:先扣减库存,再保证同一个人不能对同一张券下两单。库存用Redis预减加数据库乐观锁/悲观锁控制,一人一单在UserController层的下单方法里做了查询校验,配合数据库对(user_id, voucher_id)的唯一约束兜底。

这个逻辑本身是能拦住重复下单的。问题出在测试方式上:如果你只有一套登录态,用一个token开1000个线程去抢同一张券,系统会把所有请求都识别成“同一个用户”,一个人本就只能买一单,于是900多次请求都被“不能重复下单”挡住,并发临界区压根没被真正打穿。你看到的响应码是整整齐齐的业务失败,而不是你想要的“1人抢到、999人抢光”。所以想让压测有点说服力,就得让每个请求代表一个不同用户,也就是每个请求头里都要带一个不同的token。

1.2 手工注册用户再登录,这条路有多不靠谱

有人会说:那我去数据库插一千个用户,然后一个一个点登录不就行了?我试过,纯属折磨自己。

黑马点评的登录流程是手机号加验证码,虽然开发环境下验证码会打印在控制台里,但你要准备1000个手机号、注册1000个用户、登录1000次、再手动存下来1000个token,中间任何一步出错都得重来。就算你用Postman的批量接口硬撑,登录接口每调一次还会自动续期Redis里的数据,堆积的脏数据还得自己清。最关键的是,手工流程根本无法回答一个问题:当某一步写错了key前缀,你该怎么排查?

所以正确思路是绕开登录接口,直接向Redis写入合法token。登录接口的本质,说白了就是“在Redis里写一条token到用户信息的映射”,你替服务端把这条映射写进去,服务端就会认为你已经登录过了。

2. 看懂黑马点评的token全链路,批量生成才不翻车

2.1 登录接口:token是怎么写进Redis的

黑马点评课程的登录代码,核心就几行。用户输完手机号和验证码,服务端校验通过后做三件事:

// 1. 创建/获取用户后,转成UserDTO(只包含id、nickName、icon三个字段) UserDTO userDTO = new UserDTO(user); // 2. 生成一个UUID作为token String token = UUID.randomUUID().toString(); // 3. 以Hash结构写入Redis,key是 login:token: + token,30分钟过期 String tokenKey = LOGIN_USER_KEY + token; Map<String, Object> userMap = BeanUtil.beanToMap(userDTO); stringRedisTemplate.opsForHash().putAll(tokenKey, userMap); stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

注意几个关键细节:

  • key前缀:课程里定义在UserConstants中,LOGIN_USER_KEY = "login:token:",后面拼上UUID就是完整key。
  • Hash字段:不是整个User对象,而是UserDTO的三个字段id、nickName、icon。新版课程用BeanUtil.beanToMap把DTO转成Map,然后opsForHash().putAll()写进Redis;老版本也有用JSON字符串存的,字段结构一样。
  • TTL:30分钟。这就是token的“寿命”,之后每次请求只要带着token,拦截器会刷新这个过期时间。

想批量造token的突破口就在第3步:你不需要真的调登录接口,自己拼好这组Hash数据,往Redis里一放,就等价于完成了1000次登录。

2.2 双拦截器:token校验与滑动续期的关键设计

黑马点评的token校验用的是两个拦截器,这个设计很值得多看两遍,因为它直接决定了你批量生成token时需要注意什么。

第一个是RefreshTokenInterceptor,拦截所有请求,优先级最高。它的逻辑是:如果请求头里带了authorization字段,就去Redis查login:token:+ token这个key;查到了就把用户信息塞进ThreadLocal,同时调用expire刷新TTL回到30分钟。没查到也没关系,放行,交给下一个拦截器判断。

第二个是LoginInterceptor,拦截需要登录的接口路径,比如秒杀下单、查看自己的订单这些。逻辑更简单:ThreadLocal里有用户就放行,没有就返回401。

// 拦截器注册时,需要登录的路径由 excludePathPatterns 反选 registry.addInterceptor(loginInterceptor) .excludePathPatterns( "/shop/**", "/voucher/**", "/shop-type/**", "/upload/**", "/blog/hot", "/user/code", "/user/login" );

这套设计有两个关键含义:

第一,任何带token的合法请求,都会自动续期。哪怕这个token是你脚本塞进去的,只要请求还在持续打,它到死都不会过期。这其实就是“滑动续签”的一种最朴素的实现。

第二,拦截器只认Hash里的字段名。它拿到Redis里的Map后会用BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false)做反序列化,字段名必须和UserDTO完全对得上,否则用户信息就是空的,LoginInterceptor照样把你当成游客。

2.3 对照JWT:Redis token的优势与续签思路

很多同学学到这儿会纠结:为什么课程不用JWT?这其实是黑马点评设计上很聪明的一笔,也是面试官爱问的点。我整理了一个对照表:

维度Redis token(黑马点评)JWT
主动失效删掉key立即踢下线签发后很难撤销,得额外维护黑名单
续期每次请求刷新TTL,天然滑动续期过期时间固定,续签得设计refresh token或再存Redis
用户信息存在服务端,可控可查存在客户端,服务端不可控
体积一个短key,Redis里就是Hashtoken自带payload,体积明显更大
适用场景需要服务端会话管理分布式无状态认证、跨端共享

网上搜“jwt实现token续签”能搜出一堆方案,核心无非是refresh token双token机制、或者把有效载荷放进Redis让JWT变成可控状态。理解了这个对照,你就知道黑马点评的Redis方案为什么天然没有这个痛点:每个请求的TTL刷新就是续签。批量生成token时,你只要把TTL设置得够长,或者确保压测期间请求持续不断,就不会踩“token中途失效”的坑。

2.4 token失效的常见场景与排查线索

结合大家常搜的“token失效”问题,我把实际会遇到的情况归个类:

  • TTL到期且期间无请求:30分钟没有任何携带该token的请求,key被Redis清理,再访问就是401。这是设计行为,不是bug。
  • 主动退出登录:项目里有登出逻辑,会delete掉login:token:这个key,token立即作废。
  • Redis重启且没有持久化:如果本地Redis没开RDB或AOF,重启后所有token丢光。很多同学改完代码重启服务器,发现前端全部掉线,查半天才发现是Redis重启导致。
  • Redis库选错:spring.data.redis.database配置的是0,但你脚本连的是db 1,或者反过来。token写到了别的库,服务端自然查不到。
  • 请求头字段问题:后端拦截器读的是request.getHeader("authorization"),传入时字段名不对、大小写不规范,token就传不进来。

排查思路也有固定套路:先用redis-cli查一遍key是否存在,存在再看Hash字段值对不对,最后用curl带token打一个需要登录的接口。三分之一的问题出在“key不存在”,三分之一出在“字段不匹配”,剩下的是环境问题。下面的批量生成方案里,我针对这些坑都做了预防。

3. 自动生成1000个token的三种实现方式

3.1 方案一:CommandLineRunner一次性生成并落盘

这是我最推荐的方案,原因就一条:不污染正常业务代码,只在启动时加一个参数触发。你新建一个类,让它实现CommandLineRunner,通过判断启动参数来决定要不要执行。

@Component public class TokenBatchGeneratorRunner implements CommandLineRunner { @Resource private StringRedisTemplate stringRedisTemplate; @Override public void run(String... args) { // 没有加 --gen-token 参数时,什么都不做 if (args.length == 0 || !"--gen-token".equals(args[0])) { return; } List<String> tokens = new ArrayList<>(1000); for (int i = 1; i <= 1000; i++) { String token = UUID.randomUUID().toString(); String key = "login:token:" + token; Map<String, Object> userMap = new HashMap<>(); userMap.put("id", (long) i); userMap.put("nickName", "压测用户-" + i); userMap.put("icon", ""); stringRedisTemplate.opsForHash().putAll(key, userMap); stringRedisTemplate.expire(key, 30, TimeUnit.MINUTES); tokens.add(token); } // 把token写到一个文件,一行一个,方便JMeter等压测工具引用 try { Files.write(Paths.get("tokens.txt"), tokens, StandardCharsets.UTF_8); } catch (IOException e) { throw new RuntimeException("写入tokens.txt失败", e); } System.out.println("已生成1000个token并写入tokens.txt"); System.exit(0); } }

使用方式:IDE里给启动类加--gen-token运行参数,或者打好jar后执行java -jar hm-dianping.jar --gen-token。跑完当前目录下会多出一个tokens.txt,1000个token每行一个。

这里有几个细节我想特意强调:

  • id字段一定要放Long型,userMap.put("id", (long) i),不要放String。BeanUtil.beanToMap出来的map里id本来就是Long,拦截器反序列化时再做一次类型转换,一致性最好。
  • UUID带不带横杠无所谓,它不是JWT,解密环节,只是一个Redis key的字符串后缀。课程代码原样是UUID.randomUUID().toString(),你保持一致就行。
  • 生成完直接System.exit(0),防止Spring容器还在初始化后面的组件,白占资源。

3.2 方案二:SpringBootTest测试类批量写入

如果你不想动启动流程,也不想加什么启动参数,那就写个测试类,利用@SpringBootTest加载完整应用上下文,跑一个JUnit用例完事。

@SpringBootTest public class TokenGenerateTest { @Resource private StringRedisTemplate stringRedisTemplate; @Test void generate1000Tokens() throws IOException { List<String> tokens = new ArrayList<>(1000); for (int i = 1; i <= 1000; i++) { String token = UUID.randomUUID().toString(); String key = "login:token:" + token; Map<String, Object> userMap = new HashMap<>(); userMap.put("id", (long) i); userMap.put("nickName", "mock-" + i); userMap.put("icon", ""); stringRedisTemplate.opsForHash().putAll(key, userMap); stringRedisTemplate.expire(key, 30, TimeUnit.MINUTES); tokens.add(token); } Files.write(Paths.get("tokens.txt"), tokens, StandardCharsets.UTF_8); } }

这个方案的好处是IDE里右键直接运行,不改变应用主类的任何行为;坏处是需要保证测试环境连的Redis是对的,而且每次跑测试都会加载整个Spring上下文,稍微慢点。如果你在application-test.yml里改了Redis地址,记得别和开发环境搞混。

3.3 方案三:Python或Redis命令直写,不启动Java服务

有时候你压根不想启动Java服务,比如Redis已经跑着、项目代码也没改过,只想快速手动塞一批数据。这时用Python的redis-py最顺手:

import redis import uuid # db必须和项目application.yml里 spring.data.redis.database 一致 r = redis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True) pipe = r.pipeline(transaction=False) with open("tokens.txt", "w", encoding="utf-8") as f: for i in range(1, 1001): token = uuid.uuid4().hex key = f"login:token:{token}" pipe.hset(key, mapping={ "id": i, "nickName": f"stress-{i}", "icon": "" }) pipe.expire(key, 1800) f.write(token + "\n") pipe.execute() print("生成完毕")

如果用纯命令行,核心就是两条Redis命令:

# 写一条Hash redis-cli hset "login:token:随机token串" id 1 nickName "stress-1" icon "" # 设置30分钟过期 redis-cli expire "login:token:随机token串" 1800

纯命令的问题在于随机token串得你自己生成,而且Hash字段多、还要逐条设过期时间,脚本化更靠谱。用Redis这种直写方案时,最需要注意的是decode_responses=True,不然id取出来是bytes,脚本里比对会出问题。

3.4 三个方案怎么选

方案优点缺点适用场景
CommandLineRunner一次成型,不动测试逻辑,产出tokens.txt需要加启动参数打完包后在服务器上直接生成
SpringBootTest测试类IDE一键运行,不影响启动流程需要全套Spring上下文本地开发调试、随手造数据
Python/Redis直写服务不用启动,可批量可定时需要Redis客户端环境临时造大量数据、环境初始化

我个人在项目里最终留下的是第一个方案,因为它在服务器上一条命令就能造完token,后面再配合JMeter做定期压测,脚本化程度最高。

4. token生成后的验证与并发测试落地

4.1 三步验证法:确认token真能用

造完token别急着压测,先花30秒验证,避免带着坏数据白跑一趟。

第一步,Redis侧验证。随便取一个token去查:

redis-cli hgetall "login:token:<刚才生成的某个token>"

能看到id、nickName、icon三个字段和对应值,说明写入格式没问题。

第二步,数量验证。

redis-cli keys "login:token:*" | wc -l

本地开发环境数据量不大,用keys没问题;如果Redis里已有线上数据,建议改用scan。正常应该大于等于1000。

第三步,接口侧验证。找一个需要登录的接口,比如秒杀下单接口,不带token访问应该是401,带上token访问就能通过拦截器进入业务逻辑:

curl -i -X POST http://localhost:8080/api/voucher-order/seckill/1001 \ -H "authorization: <你的token>"

返回结果只要不是401,就说明拦截器放行了——它识别出了你脚本造的token,认你为合法登录用户。

4.2 用JMeter批量投喂token做千人秒杀

验证接口通过后,就可以上JMeter了。流程是这几步:

  1. 创建tokens.txt,每行一个token,顺序无所谓。
  2. 添加CSV Data Set Config,文件名指向tokens.txt,变量名填token,Recycle on EOF设为false,Stop thread on EOF设为true。这样1000个线程正好一人领一个token,不会重复。
  3. 添加HTTP请求,指向秒杀接口,比如/api/voucher-order/seckill/{voucherId},方法选POST。
  4. 添加HTTP Header Manager,加一个请求头,名称authorization,值${token}。
  5. 线程组里设1000个线程,Ramp-up建议从5秒开始,先别急着1秒全部打出去,给服务和Redis一个观察过程。

跑完之后去看响应数据:1000个请求里,应该只有少数几个返回成功下单,其余返回“库存不足”或者“不能重复下单”。

如果你不想上JMeter,也可以用Java写一个简单的并发测试,用CountDownLatch控制1000个线程同时发起请求,核心思路一模一样:每个线程从tokens.txt取走一个不同token,构造HTTP请求打秒杀接口。

4.3 压测完成后,怎么确认一人一单真的生效

压测跑完,数据校验比看响应更要紧。我一般查三处:

第一处,数据库订单表。查tb_voucher_order里该优惠券对应的订单数量,如果库存设的是1,期望订单数是1条;如果同一个人下了两单,说明一人一单的锁失效了。

第二处,库存表。查tb_seckill_voucher的stock字段,正常应该是0。如果库存变成负数,说明扣减逻辑有并发漏洞。

第三处,如果项目做了异步秒杀改造(Redis Stream + 阻塞队列),还要去看Redis Stream里的待处理消息数量和消费速度,判断异步削峰是否达到预期。

这三处都对上了,才说明你造的1000个token真正起到了作用,把系统的并发能力完整压了出来。

4.4 别忽略一个细节:这些“假用户”要不要入库

只造token不造用户,秒杀下单时订单表里会落一条user_id指向不存在的用户。如果你只用新号测试秒杀,问题不大;但如果后面要测“下单后查我的订单”、“用户积分”、“优惠券使用记录”这种关联功能,假用户就会暴露出数据缺失问题。

我给的建议是分情况:纯压测秒杀,假ID完全够用;要测完整业务闭环,就老老实实先往tb_user批量插入用户记录,插的时候注意phone字段唯一性。你也可以把CommandLineRunner里的生成逻辑扩展一下,生成token的同时把用户也插入数据库,一步到位。

5. 批量造token的实战踩坑与经验

5.1 连错Redis库:token写进去了,服务却查不到

这是我唯一一次被这个问题卡了半个小时的情节:脚本跑完提示生成成功,hgetall查出来的数据也有模有样,但接口访问就是401。最后发现脚本里连的是db=1,而项目的application.yml写的是spring.data.redis.database: 0。

排查方法很简单,先确认你连的是哪个库:

redis-cli -n 0 keys "login:token:*" | wc -l redis-cli -n 1 keys "login:token:*" | wc -l

哪个库有1000个key,哪个才是项目真正用的库。造数据之前看清配置文件,比事后排查省一小时。

5.2 Hash字段与UserDTO不匹配,导致登录态为空

还有一次,我图省事,直接用了User实体的字段,写进去五个字段:id、phone、password、nick_name、icon。结果拦截器虽然从Redis里取到了数据,但BeanUtil.fillBeanWithMap往UserDTO里填的时候,password和nick_name根本匹配不上UserDTO里的nickName,最后填进去的UserDTO缺字段,登录态形同虚设。

后来我老老实实回到课程代码的原样:Hash里的字段名严格用id、nickName、icon,别的什么都不放。这个教训浓缩成一句话:批量造token,本质上是在模拟登录代码的写库行为,不是凭感觉造一套新结构。

5.3 TTL设定与压测时长的配合

默认30分钟TTL本身够用,因为双拦截器里RefreshTokenInterceptor每次请求都会刷新TTL。只要你的压测是连续的,token就不会中途挂掉。

但如果你遇到这种情况:早上生成token,下午才想起压测,中间隔了好几个小时,key早就被Redis清掉了。所以我写生成脚本时,会把TTL设成可配置的,压测专用就改成24小时:

stringRedisTemplate.expire(key, 24, TimeUnit.HOURS);

压测结束后,手动把这些key清掉,免得堆在Redis里占内存。1000个key其实不到1MB,但养成清理习惯没坏处。

5.4 慎用FLUSHALL:共享Redis的清理姿势

很多同学测试完喜欢来一发FLUSHALL清数据,这在本地自娱自乐没问题,但如果你和同事共用一台Redis,或者项目连的是测试环境公共Redis,这一下就能把别人的会话、验证码、缓存全部带走。

正确的清理方式只针对自己的key:

redis-cli --scan --pattern 'login:token:*' | xargs -r redis-cli del

这句话把所有login:token:前缀的key删掉,不会碰其他数据。我把它写进自己的笔记里,每次压测完都跑一遍。

5.5 服务端处理不过来时的排查思路

1000个线程打过去,如果出现大面积的连接超时、响应变慢,别第一反应怀疑“token是不是不行”。看两件事:第一,Redis的慢日志和连接数,秒杀场景Redis承担了幂等判断、库存预减,负载压力不小;第二,Tomcat线程池配置,默认200个线程扛1000并发时,肯定会有排队等待。

这种情况把JMeter的Ramp-up拉长,比如从1秒改成10秒,让请求平缓进入,一般就能看到正常的业务响应。批量生成token只是压测的第一步,真正要调的其实是整个链路的吞吐参数。

5.6 一点实用心得

把批量生成token跑通之后,我最意外的收获是对这套登录体系的理解上了一个台阶:以前只知道“登录返回token”,现在闭着眼都能说出Redis里存的是什么结构、拦截器怎么刷新过期时间、什么场景会让token失效。后面面试有人问我“登录态怎么设计”“token过期怎么续签”,我直接把黑马点评的这套方案讲出来,再补一段“如果用JWT该注意什么”的对照分析,基本都能聊到点子上。

如果你也在p70这个位置卡住,强烈建议按这个方案走一遍,把tokens.txt留好。后面做优惠券秒杀、好友关注、达人探店这些功能的全链路测试时,这批token还能继续用。最后一个小提醒:生成的token虽然能让你“伪装”成任何一个用户,但毕竟绕过了真实注册登录流程,在正式环境千万别这么干,这一套只属于开发和压测环境。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询