
做黑马点评的人大概率都经历过这种尴尬时刻教程一路推到p70秒杀功能代码抄得一字不差但你想把“一人一单”真正放到并发场景下验证时发现整个项目就那么一两个测试账号登录一次只能拿一个token。用一个token并发打一千次系统当然能拦住重复下单可你要验证的是“一千个不同用户同时抢同一张券”没有一千个token这个场景根本测不出来。我就是在这个节点上折腾出了整套“自动生成1000个token”的方案跑通之后不但压测顺利还把项目里token从生成、校验到滑动续期的机制彻底搞明白了面试被问到登录态设计时也有了实打实的案例。这篇文章把这些东西完整整理出来给正在跟课、或者项目做完想深挖一遍的Java学习者做个参考。1. p70的秒杀压测卡点为什么非得备好1000个token1.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结构写入Rediskey是 login:token: token30分钟过期 String tokenKey LOGIN_USER_KEY token; MapString, 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字符串存的字段结构一样。TTL30分钟。这就是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 对照JWTRedis token的优势与续签思路很多同学学到这儿会纠结为什么课程不用JWT这其实是黑马点评设计上很聪明的一笔也是面试官爱问的点。我整理了一个对照表维度Redis token黑马点评JWT主动失效删掉key立即踢下线签发后很难撤销得额外维护黑名单续期每次请求刷新TTL天然滑动续期过期时间固定续签得设计refresh token或再存Redis用户信息存在服务端可控可查存在客户端服务端不可控体积一个短keyRedis里就是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:这个keytoken立即作废。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; } ListString tokens new ArrayList(1000); for (int i 1; i 1000; i) { String token UUID.randomUUID().toString(); String key login:token: token; MapString, 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.txt1000个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 { ListString tokens new ArrayList(1000); for (int i 1; i 1000; i) { String token UUID.randomUUID().toString(); String key login:token: token; MapString, 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(host127.0.0.1, port6379, db0, decode_responsesTrue) pipe r.pipeline(transactionFalse) with open(tokens.txt, w, encodingutf-8) as f: for i in range(1, 1001): token uuid.uuid4().hex key flogin:token:{token} pipe.hset(key, mapping{ id: i, nickName: fstress-{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_responsesTrue不然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了。流程是这几步创建tokens.txt每行一个token顺序无所谓。添加CSV Data Set Config文件名指向tokens.txt变量名填tokenRecycle on EOF设为falseStop thread on EOF设为true。这样1000个线程正好一人领一个token不会重复。添加HTTP请求指向秒杀接口比如/api/voucher-order/seckill/{voucherId}方法选POST。添加HTTP Header Manager加一个请求头名称authorization值${token}。线程组里设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。最后发现脚本里连的是db1而项目的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这一下就能把别人的会话、验证码、缓存全部带走。正确的清理方式只针对自己的keyredis-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虽然能让你“伪装”成任何一个用户但毕竟绕过了真实注册登录流程在正式环境千万别这么干这一套只属于开发和压测环境。