做性能测试或接口自动化的时候,不少人都遇到过这种抓狂的场景:登录接口单独跑,状态码200,返回数据正常;一旦把登录和后面的业务接口串起来跑,业务接口却一个接一个地报401 Unauthorized。日志里明明白白写着“登录成功”,但系统就是不认账,接口始终提示没有权限。这个问题我前前后后在不同团队排查过很多次,原因五花八门,但套路是共通的。这篇博文把常见原因、排查思路和实操方案都整理出来,正在被JMeter 401折腾的朋友可以直接对照着处理。
先说清楚,这篇文章不是讲HTTP协议的教科书,是我从实际压测项目里踩坑踩出来的经验汇总。我会从最基础的401原理讲起,一步步带你定位问题,再给出可以照着抄的JMeter配置方案。无论你是刚接触JMeter的测试新人,还是已经写过不少脚本的老手,里面总有一两个细节能帮你省下半天排查时间。
1. 先搞明白:登录成功后为什么还会401
1.1 401和403的区别,多数人第一步就搞混了
先补一个基础概念。HTTP协议里401和403都是权限相关的状态码,但含义完全不同。
401 Unauthorized,翻译过来是“未认证”,服务器的意思是:你是谁?请先证明你的身份。什么情况下会触发?请求里没有携带有效的身份凭证,比如Token没带、Token过期、Token格式不对,或者签名校验失败。服务器根本没有识别出你是哪个用户,自然直接拒绝。
403 Forbidden,翻译过来是“已禁止”,服务器的意思是:我知道你是谁,但这件事你不能做。身份认证通过了,但你这个角色的权限不够,比如普通用户去访问管理员接口,服务器就会回403。
实操中看到JMeter返回401,重点往“身份凭证缺失或无效”这个方向排查,而不是像403那样去研究角色权限配置。这一步如果方向错了,后面全白费。我见过不少测试同事把401当成权限配置问题,跑去问开发“为什么我登录了还是没有权限”,折腾好久才发现居然是Header里没带Token,时间全浪费在错误方向上。
1.2 登录后401的三个典型场景
根据我的实际观察,JMeter测试中“登录后报401”基本逃不出下面这三种场景。
场景一:登录接口返回了Token,但后续业务请求没有携带。这是最常见的原因,尤其是手写脚本或者从录制工具导入脚本后,Token没有从登录响应里提取出来并关联到后续请求。登录归登录,业务归业务,两者之间完全断开了。
场景二:Token带了,但格式不对。有些接口要求Header是Authorization: Bearer xxx,有些要求token: xxx,还有些要求自定义字段X-Auth-Token: xxx。字段名、前缀、大小写任何一个对不上,服务器都可能直接甩401。比如你把Bearer写成了bearer,或者漏掉了空格,这在日志里非常难发现,因为只看请求头会觉得“带了Token啊”。
场景三:脚本里写死了固定Token,但Token有效期很短,或者压测执行过程中服务端重启导致Token失效。这种情况在中大型压测里特别常见,脚本跑到一半,Token过期了,后续请求开始成片成片地报401,但前面一部分请求是正常的,很容易让人误判是后端的性能问题。
这三个场景加起来,至少覆盖了80%的情况。剩下20%包括并发下Token互串、系统时间不同步导致JWT校验失败、Cookie和Header混用等,这些我在后面专门讲。
2. 从抓包到定位:一套能直接上手的排查流程
2.1 先把登录态传参方式搞清楚
遇到401,第一步不是急着改脚本,而是先搞清楚被测系统到底用什么方式维持登录态。这一步往往被跳过,但恰恰是定位问题的关键。
第一种是Header传Token。登录接口返回JSON格式的响应体,里面有access_token或者token字段,后续请求在请求头里携带这个值。最典型的是Authorization: Bearer,这是RESTful API最常见的做法。但也有很多内部系统不走标准格式,直接给你一个自定义的Header名,比如X-Token、X-Auth-Token、Token等。反正Headers里传了什么,后续请求原样带上即可。
第二种是Cookie方式。登录成功后,服务端通过Set-Cookie响应头下发Session ID(常见名字是JSESSIONID,或者PHPSESSID、SESSION等),后续请求靠Cookie维持会话。这种情况在老的Java Web系统和PHP系统里特别多。注意,Cookie方式的标志性特征是浏览器的Network面板里,业务请求的Request Headers里有一个Cookie字段。
第三种是参数方式。Token放在请求参数里,要么是POST请求的Body中,要么是URL的Query String后面,比如https://api.example.com/data?token=xxxxx。这种虽然不如前两种常见,但老业务系统偶尔能碰到。
判断方式很简单:用浏览器开发者工具F12打开Network面板,手动登录一次,然后点击某个需要登录态的业务功能,查看那个请求的Headers和Payload,立刻就能看出系统用的是哪种方式。这一步不要省,我见过太多人连系统用Cookie还是Token都没搞清,就在脚本里瞎猜,结果越改越乱。
2.2 三种传参方式在JMeter里的对应配置
确认了传参方式后,在JMeter里做对应配置。
Header传Token时,在业务请求上添加一个HTTP Header Manager,填入字段名和Token值。比如系统要求Authorization: Bearer,那就在Name填Authorization,Value填Bearer ${token}。这里的${token}是一个JMeter变量,它的来源我们第三章详细讲。
Cookie方式时,在测试计划(Test Plan)一级添加HTTP Cookie Manager,这是关键。JMeter会自动处理Set-Cookie,把服务端下发的Cookie保存下来,并在后续请求中自动携带,不需要手动提取JSESSIONID。这里有个重要原则:配置元件的“作用域”问题。像HTTP Cookie Manager这类配置元件,只有放在父级才能作用于所有子请求。很多人把它加在单个HTTP请求下面,那它就只对该请求生效,后面的业务请求压根不会带Cookie,自然就401了。
参数方式时,直接在业务请求的Parameters列表里添加一行,Name填token,Value填${token},或者直接拼到URL后面。
这里额外强调一点:如果你用的是录制功能(HTTP(S) Test Script Recorder),录制出来的脚本往往会包含很多冗余信息,尤其是Header和Cookie相关配置需要重点审查。录制工具抓到的可能是浏览器自己补全的Header,到了JMeter里不一定通用。
3. 动态Token的解决方案与实操
3.1 方案A:正则表达式提取器
登录接口返回的Token,最常见的是JSON格式,其次是HTML或纯文本格式。无论哪种格式,想要在JMeter里把Token变成后续请求可以引用的变量,最简单的方案就是“后置处理器”。
正则表达式提取器(Regular Expression Extractor)是最通用的一个。以最常见的JSON响应为例,假设登录接口的响应是:{"code":0,"message":"success","data":{"token":"abc123xyz","expires":3600}}。
在登录请求上右键,添加 -> 后置处理器 -> 正则表达式提取器,然后配置:
- Apply to:默认的Main sample only就行
- Response Field to check:Body
- Reference Name:token(自己定义变量名)
- Regular Expression:这里要写"token"\s*:\s*"([^"]+)",注意转义双引号,JMeter的正则引擎和Java正则一致
- Template:$1$
- Match No.:1
这样配置后,登录请求执行完,变量${token}就等于abc123xyz了。然后在后续的HTTP Header Manager里引用即可。
正则提取的原理不复杂,就是用括号把要捕获的内容包起来,形成一个组,$1$表示取第一个组。正则写得好不好,直接影响提取是否成功。我常用的技巧是:先把登录接口的响应体保存下来,复制到Notepad++或任意支持正则测试的工具里,先把表达式验证通过,再往JMeter里填。
3.2 方案B:JSON提取器
如果Token以JSON格式返回,JMeter提供了更直接的JSON提取器(JSON Extractor)。它基于JSONPath语法,比正则更简洁,也不容易出错。
同样是上面的响应,JSON提取器的配置可以这样:
- Name of created variables:token
- JSON Path expression:$.data.token
- Match No.:1
- Default Value:NOT_FOUND
JSONPath写起来比正则直观得多,$.data.token的意思是:根节点data对象下的token字段。如果响应体是数组,比如data是list,那可能要用$..token之类的写法。建议配置完先跑一遍,用Debug Sampler查看变量值是否提取成功,不要直接压测,不然排查的时候还得顺带怀疑变量提取对不对。
这里我建议:能用JSON提取器的场景优先用JSON提取器,因为正则表达式在JSON解析上容易踩转义坑。尤其是Token可能是UUID、带连字符的数字字母串、甚至带特殊字符的时候,正则需要考虑更多边界情况,而JSONPath则完全不用关心值本身长什么样。但反过来,如果响应不是纯JSON(比如HTML页面包了一层),或者你是从Header响应里提取Token,正则提取器仍然是唯一选择。
3.3 方案C:BeanShell脚本处理复杂加密Token
有些系统不走寻常路,Token不是明文返回的,而是拼接了时间戳、随机数、甚至经过加密后再返回。比如有的系统登录后返回一个sign值,它是当前时间戳加上一个固定密钥做MD5之后的结果,后续请求必须携带这个sign。这种场景下,光靠提取器没法完成,因为提取出来的原始值还需要二次加工。
JMeter里推荐用JSR223 Sampler或BeanShell处理这类逻辑。注意,JMeter 3.1之后官方推荐JSR223 + Groovy,性能更好,而且BeanShell在高并发下容易出性能问题。这里我直接给一个在JSR223 PostProcessor里处理MD5签名的实际例子:
import org.apache.commons.codec.digest.DigestUtils; // 假设从响应中提取到了原始值 String rawToken = vars.get("rawToken"); String timestamp = String.valueOf(System.currentTimeMillis() / 1000); String secretKey = "your_secret_key"; // 拼接后做MD5 String signSource = rawToken + timestamp + secretKey; String sign = DigestUtils.md5Hex(signSource); // 存为JMeter变量 vars.put("sign", sign); vars.put("timestamp", timestamp);这段脚本写在登录请求的后置处理器里,作用是生成一个动态签名,把sign和timestamp分别存入JMeter变量,后续请求直接引用${sign}和${timestamp}。这里的逻辑和被测系统的加密规则强相关,你需要找开发确认具体的拼接规则,不要自己瞎猜。
强调一下:JSR223脚本里获取和设置JMeter变量的API是vars.get()和vars.put(),这是最常用的两个方法。如果需要读取上一个请求的响应体,可以用prev.getResponseDataAsString(),这在复杂场景下会用到。脚本里不要写死任何业务数据,所有可能变的量都从响应或请求参数里动态取,否则换个环境脚本就废了。
4. 常见问题:并发场景下401排查实录
4.1 单用户正常,并发一上来就报401
这是最诡异的一类问题:拿单个线程跑脚本,一切正常;把线程数调到50甚至100,马上出现一批401。很多人第一反应是后端限流或Token过期,但实际上还有一个非常隐蔽的原因——共享变量冲突。
JMeter默认情况下,同一线程组内的每个线程是独立执行脚本的,变量是线程私有的吗?答案是:分情况。如果你用正则提取器或JSON提取器设置的变量,在JMeter 5.x默认配置下是线程独立的,不会互相覆盖。但是,如果你用了__setProperty()这类函数去设置全局属性,或者用了BeanShell脚本直接操作JMeter Properties,那在多线程下就会出现变量互串。
另一个更常见的原因是共享登录状态。比如你设计了一个“只登录一次”的脚本,把登录请求放在了setUp线程组,然后把Token存成了全局属性,所有业务线程共用同一个Token。当并发量达到一定程度,服务端可能会因为多请求共享同一Token触发风控策略,主动失效Token或者限制频率,结果就是后面的请求大量401。
解决办法一是把登录请求放进业务线程组,每个线程都先登录再执行业务,保证每个用户有独立的Token。缺点是这样会拉高压测的登录接口QPS,如果你测的是业务接口而不是登录接口,建议单独评估。办法二是用CSV数据准备多组账号,结合setUp线程组把登录后的Token写入CSV文件,业务线程组读取各自Token。这样做虽然复杂一些,但贴合实际使用场景。
我遇到过一个更隐蔽的情况:压测机的系统时间和服务端不一致,导致JWT每隔一段时间就校验失败。JWT里面通常会带iat(签发时间)和exp(过期时间),服务端校验时如果发现时间偏差超过容忍范围,直接判定Token无效。这种问题排查起来很头疼,因为单次跑偶尔成功偶尔失败,时间漂移不明显时很难归因。你可以用JMeter里添加一个简单的HTTP请求去调用系统时间接口,比对客户端和服务端的时间差。
4.2 服务端会话过期导致的大批量401
压测时间长了,尤其是跑稳定性测试那种按小时计算的任务,很可能会遇到中途大批量401的情况。原因很简单:Token有效期到了。有的系统Token有效期只有30分钟,你的压测脚本跑了一个小时,登录只在脚本开始时执行了一次,Token过期后自然所有请求全部401。
这个问题不是JMeter配置的问题,而是测试设计的问题。你需要在压测过程中周期性地重新登录、刷新Token。实现方案有三种,按推荐程度排:
方案一,把登录做成一个独立的“登录模块”,通过循环控制器或定时器,每隔一段时间重新执行登录请求,并把新Token更新到变量中。适合Token有效期较短、业务请求依赖登录态的场景。
方案二,在脚本里调用刷新Token的接口。很多系统提供refresh_token机制,登录时返回两个Token:access_token有效期短,refresh_token有效期长。access_token过期后,用refresh_token换新的access_token。如果你被测系统支持这个机制,务必在脚本里实现,因为这才是最贴合生产环境的做法。
方案三,如果系统没有刷新Token接口,那就只能定期跑一个登录请求并把新Token写到CSV文件,业务线程定期重新读取。这个方案比较笨拙,但在极端情况下能救急。
此外,排查稳定性测试中段401问题时,一定要看一下服务端日志。很多情况下Token其实没过期,而是服务端重启了(比如JVM OOM被自动拉起,或者发布系统定时发布了版本),所有内存中的会话状态全部丢失。这不是脚本问题,是测试环境不稳定。你可以在压测开始时、报401的时间点分别看一下应用日志,有没有进程重启的痕迹。
4.3 多机压测时的Token关联问题
分布式压测时,JMeter Master和多个Slave协同工作,这时Token问题会放大。最常见的问题是:你把Token写死成了本地某个JMeter变量,结果只有部分Slave上的请求带了正确的Token,其他Slave全是401。
本质原因是,JMeter变量在每个Slave的JVM里是独立的,Master设置的变量并不会自动分发到所有Slave。如果你在Master上用BeanShell生成并保存了Token,那只有Master自己的线程能拿到;Slave上的线程执行请求时,Token变量是空的或默认值。
解决思路是把Token的生成放到每个Slave上自己执行。也就是说,登录请求放在业务线程组内部,让每个Slave、每个线程都执行自己的登录逻辑。如果你不希望每个线程都登录一次,也可以在每个Slave上预置一个CSV文件,里面放该Slave自己的Token,通过CSV Data Set Config来读取。
还有一点,分布式压测时所有Slave的系统时间必须同步,否则JWT之类的Token可能因为时间戳偏差而校验失败。建议在压测前用NTP同步所有压测机的系统时间,这是很多团队容易忽视的细节。
5. 问题排查速查表与避坑清单
5.1 一张表快速定位你的401原因
我把平时排查401时最常用的检查点整理成了一张速查表,遇到问题对照着过一遍,基本能覆盖九成场景。
| 排查点 | 检查方法 | 常见结果 |
|---|---|---|
| 是否携带Token | View Results Tree里查看业务请求的请求头 | 请求头里没有Authorization或自定义Token字段 |
| Token变量是否提取成功 | 添加Debug Sampler查看变量值 | 变量值为空或NOT_FOUND,说明提取器配置有问题 |
| Token格式是否正确 | 和浏览器抓包请求头逐字符比对 | Bearer拼写错误、大小写不对、多了空格 |
| 是否是Cookie维持会话 | 检查是否添加HTTP Cookie Manager | 场景是JSESSIONID,但脚本里没有Cookie管理器 |
| Token是否过期 | 查看登录时间和当前压测时间的差值 | 压测超过30分钟且没有刷新Token逻辑 |
| 是否多线程共享Token | 检查脚本是否用了全局属性存Token | 并发上来后大量401,单线程正常 |
| 系统时间是否同步 | 对比压测机和服务端时间 | 时间漂移超过JWT允许的偏差范围 |
| Header大小写或字段名错误 | 用HTTP Header Manager逐项核对 | 字段名是X-Auth-Token,脚本写成了X_Auth_Token |
| 请求必须携带Referer | 查看抓包请求的Referer头 | 登录后按业务时序访问,但脚本没加Referer |
这张表是我自己排查时的默认清单,建议你也把它保存下来。注意,排查401问题时,最忌“凭感觉猜”。每一步判断都要基于抓包数据或JMeter的查看结果树,别靠推测。
5.2 几个必须养成的实操习惯
最后分享几个我在实际项目中踩出来的实操习惯,算不上高深,但很管用。
第一,所有涉及Token的脚本,必须在正式压测前用Debug Sampler验证变量提取。在测试计划中添加一个Debug Sampler,勾选JMeter Variables,然后在查看结果树里看变量列表。这一步一分钟就能完成,能避免后面浪费半小时甚至更久去排查“为什么401”的问题。
第二,写脚本时把登录请求单独拆到一个线程组或模块里,用逻辑控制器管理执行顺序。这样做的好处是,一旦出现认证相关的问题,你可以快速单独验证登录环节,而不需要在几百个请求里找线索。包括我自己的习惯做法:业务接口的每个请求都配上注释,标明它是从哪个登录态得来的Token,这样交接给同事时也不至于全靠口述。
第三,遇到偶发401,排查时一定要看查看结果树里的“响应体”,而不是只看状态码。很多时候服务器会在响应体里告诉你具体原因,比如“Token expired”“Invalid signature”“Missing bearer token”。这些信息比任何猜测都更准确。实际上,这条经验适用于所有接口报错排查,不限401。
第四,保持脚本的可维护性。不要在一个HTTP Header Manager里写死几百个请求共用的Token值,而是统一使用变量。现在看起来只是多写了一个${},但当你需要换环境、换账号、换压测策略时,这个习惯能节省大量的修改时间。我有一次接手别人的压测脚本,整个脚本里到处都是写死的token值,要改成动态Token牵扯了四十多个请求,改到怀疑人生。所以,从一开始就用变量,回报率极高。
6. 写在最后的几点个人体会
关于JMeter的401问题,我在实际项目里兜兜转转踩了很多坑之后,最大的体会是:这类问题本质上不是JMeter的坑,而是你对被测系统的认证机制理解不够深。JMeter只是工具,它忠实地发送了你配置的请求。如果请求缺少Token、Token格式不对、Token过期了,那是脚本没有正确模拟真实用户的行为,不是JMeter“坏了”。
所以每次遇到401,我都会先问自己三个问题:真实用户的浏览器里,登录后请求会带什么?这个请求头是从哪里来的?它在什么条件下会失效?把这三个问题想清楚了,90%的401问题都能找到答案。剩下的10%,多半是环境问题,比如时钟不同步、服务端重启、风控策略拦截,这些只能靠日志和耐心一点一点排查。
下次你在JMeter里看到一片刺眼的401,别慌,按照这篇文章的顺序,先看抓包,再查脚本,最后看环境。希望这篇经验汇总能帮你少走弯路。