凌晨两点,我被一条用户反馈弹醒:“我的订单详情里,出现了别人的收货地址和手机号。”那一瞬间我脑子里闪过的第一反应,是某个数据查询条件写错了,where条件漏了用户维度,或者SQL join出了问题。我翻了一晚上业务代码,查询条件是id = ?,权限中间件也验了,日志里请求用户ID完全正确,怎么看都该是正常返回。但问题就摆在线上,而且不是偶发,是特定条件下必现。
数据串号这种事,十有八九都和共享状态有关,而隔离问题又往往藏在那些看起来很正常的黑盒组件里。我后来把目光挪到了一个用了两年、从未出过问题的认证模块上——它对外只暴露一个 verify 接口,输入一个凭证,返回当前用户信息。典型黑盒。为了撬开它,我做了一场口令实验,也正是这场实验,让我找到了真正的病灶所在。
这篇文章不打算讲什么高深理论,就完整还原一下那次排查过程:从用户反馈、实验设计、黑盒拆解,到修复验证,再到我从里面总结出来的一套通用排查方法。
1. 一次诡异的串号:用户视角和开发者视角的错位
1.1 用户看到的Bug,和我看到的Bug完全不是一回事
用户反馈的原文是:“我今天上午下的订单,下午打开详情页,发现收货人是一个我完全不认识的人,电话和地址也是他的。但我下单的时候明明填的是我自己的。”
这是非常典型的“越权访问”表象,但用户并不关心什么是越权,用户只关心“我看到了别人的数据”。而我在日志里看到的是另一个画面:用户A请求订单详情,接口返回了订单B的数据,但是日志中记录的操作者ID、请求Token、权限校验结果全部都是用户A。
换句话说,在日志的视角里,整个链路是自洽的。A登录了,A带着自己的Token请求数据,A通过了权限校验,然后系统返回给A的数据却是属于B的。这就像你去银行取钱,柜员核对了你的身份证,然后从别人的账户里给你取了现金。
这种“日志自洽但结果错误”的情况最让人头疼。因为它意味着问题不在业务逻辑层,不在最外层的权限控制,而在某个更深的、被封装起来的环节里——某个地方把“A的身份”和“B的数据”错误地关联到了一起。
1.2 认证模块:那个用了两年没出过问题的黑盒
我们系统里有一个基础认证组件,是另一个小组维护的,内部封装了Token解析、签名验证、会话缓存、用户信息查询等一堆逻辑。对外只提供一个接口:
- 输入:一串不透明的凭证(Token)
- 输出:当前用户ID、租户ID、角色列表
业务侧所有接口在进入真正的逻辑之前,都要先调它,拿到“当前请求上下文”,然后基于这个上下文去查数据。两年来这个模块一直很稳定,没人怀疑过它。
但在排查这个串号问题时,我逐渐意识到一个尴尬的事实:我们对这个模块的内部实现一无所知。我不知道它用了什么缓存,不知道它的key是怎么组织的,不知道它是否依赖了线程本地变量,甚至不知道它在高并发下是否存在共享可变状态。
这种“只知道输入输出,不知道内部实现”的组件,就是典型的黑盒。黑盒在架构上有很多好处:团队可以独立演进、调用方不用关心细节、升级只要保证接口不变就行。但它的坏处也很明显:当问题藏到黑盒内部时,你只能靠外部实验去推断内部行为,而这种推断过程,非常考验实验设计能力。
那天晚上,我决定不再盯着业务代码看,而是设计一场实验,把黑盒内部的行为逼出来。
2. 口令实验:把黑盒变成可观测灰盒的关键一步
2.1 为什么选“口令”作为探针
所谓口令,就是那个 Token。之所以拿它做实验探针,原因很简单:在认证链路里,Token 是唯一由外部输入、并且可以完全由我控制的可变变量。数据查询条件、接口路径、请求参数我都能控,但 Token 是穿过整个认证模块的唯一身份凭证。
这里我用了以前排查门禁系统问题的类比:如果你怀疑某扇门禁系统没有做区域隔离,最直接的验证方式,就是拿A区域的卡去刷B区域的门。如果刷开了,就说明门禁系统本身对区域信息的隔离是有问题的。同理,在认证链路中,如果我拿用户B的口令去请求用户A的资源,系统仍然返回了A的数据,那就说明令牌背后关联身份的环节出了岔子。
这个实验的本质是“可控输入 + 可观测输出”。口令是可控输入,返回的数据归属是可观测输出。通过不断交叉组合这两个维度,就能逐步缩小问题的定位范围。
2.2 实验的三个维度和预期结果
我设计了三组实验,每一组都对应一个明确的判断标准:
| 实验组 | 请求身份 | 请求资源 | 预期正确结果 | 实验目的 |
|---|---|---|---|---|
| 对照组 | 用户A的口令 | 用户A的资源 | 正常返回A的数据 | 确认链路的基本可用性 |
| 同租户串号复现 | 用户B的口令 | 用户A的资源 | 返回403或无数据 | 验证是否存在越权 |
| 跨租户越权 | 租户B的用户口令 | 租户A的资源 | 返回403或无数据 | 验证租户隔离是否失效 |
我的判断逻辑很简单:如果第1组正常,说明整个接口链路没有大问题;如果第2组也正常,说明用户间的数据隔离没问题;如果第3组出了问题,那就能顺势往“共享状态”的方向去查了。理论上,认证模块应该在解析Token后,把请求上下文里写入正确的用户和租户信息,业务层再拿这些信息去查数据。如果这个“写入”环节依赖了某些共享状态,比如一个以不完整标识为key的缓存,或者一个被错误复用的线程上下文,那么就会出现“身份错乱”的现象。
2.3 动手执行:三组请求的结果
实验过程不复杂,我直接用 curl 模拟。为了不污染线上环境,我在预发环境构造了用户A、用户B以及租户C的用户D,分别准备了一批只属于各自的数据,然后用各自的Token做交叉请求。
# 对照组:用户A的Token访问用户A的资源 curl -H "Authorization: Bearer token_of_A" \ https://pre.example.com/api/resource/A # 同租户交叉:用户B的Token访问用户A的资源 curl -H "Authorization: Bearer token_of_B" \ https://pre.example.com/api/resource/A # 跨租户交叉:租户C用户D的Token访问用户A的资源 curl -H "Authorization: Bearer token_of_D" \ https://pre.example.com/api/resource/A前两道命令的结果符合预期:A访问A正常,B访问A被拒。但第三道命令的结果让我后背发凉:
租户C的用户D,拿着自己的口令,成功请求到了租户A的用户A的资源,而且HTTP状态码是200,返回了完整的业务数据。
那一刻我基本可以确定:这绝不仅仅是普通用户之间的越权,而是租户层面的隔离失效。而隔离失效,通常意味着系统里某个环节把不同租户、不同用户的状态混到了一起——也就是标题里说的“共享状态,隔离问题”。
3. 拆开黑盒:共享状态到底在哪里失效了
3.1 日志和缓存里的关键线索
实验复现之后,下一步就是拆盒。我先给认证模块临时开了Debug日志,然后让测试同学又跑了一遍跨租户请求。这次日志给了我两个关键线索:
第一,Token解析出的用户ID、租户ID都完全正确,说明Token本身的签名和内容没有被篡改,签名校验是过关的。
第二,在解析完Token之后,认证模块并不是直接去数据库查用户信息,而是先查了一层Redis缓存。缓存key长这样:
session:10086问题就从这里冒出来了。这个key里只有用户ID,没有租户ID。换句话说,认证模块在缓存会话信息的时候,用了一个“全局唯一但实际并不唯一”的标识来定位状态。
于是我立刻去Redis里查了一下,发现这个key果然存在问题:当不同租户下恰好存在相同userId的用户时,后登录的那个人会直接命中前一个人留下的缓存Session。session:10086 可能是租户A的用户A,也可能是租户C的用户D,谁最后写入了,谁就拥有这个key。
3.2 从Token解析到上下文填充:故障时序还原
完整的故障时序,比我想象的还要靠前几步。我这里画了一个文字版的时序说明:
- 租户C的用户D发起请求,携带自己的Token;
- 认证模块解析Token,得到 userId=10086,tenantId=C;
- 模块查询缓存,尝试通过 session:10086 获取用户上下文;
- 缓存命中,但里面存的是之前租户A用户A的会话信息;
- 模块没有重新校验 tenantId 和 userId 是否匹配,直接把缓存里的A的用户上下文填充到了当前请求中;
- 后续业务代码读取上下文,拿到的全是A的身份信息;
- 业务层用A的身份去查询数据,返回A的数据。
说白了,黑盒内部有两个共享状态点同时出了问题:一个是以“不完整标识”为key的共享缓存,另一个是对缓存结果不加二次校验的信任逻辑。第一个问题是源头,第二个问题是帮凶。如果只有第一个问题,验证逻辑足够严格,最多是多了一次缓存误命中,后续校验还是会把请求拦下来。但两个问题叠加,就造成了跨租户的数据泄露。
3.3 为什么单纯靠Token校验挡不住这个问题
很多人会问:Token校验都过了,怎么还会串号?
这里必须分清楚两件事:Token校验,校验的是“你是谁”;数据权限控制,决定的是“你能看什么”。两者不是一回事。大多数系统里,Token校验只负责解析身份并填充上下文,如果这个“填充上下文”的环节返回了别人的身份,那后面所有基于身份的权限判断都建立在错误的前提上,权限控制再严格也是白搭。
这就好比酒店门禁卡系统:门禁卡本身能证明你是某间房的住客,但如果前台发卡时,把你的身份信息关联到了另一个人的房卡记录上,那你拿着自己的卡去刷,开的就是别人的房门。门锁校验是好的,但身份关联是错的。
所以,任何依赖“上下文身份”做数据过滤的系统,都要在“身份解析”这个环节对结果做二次确认。这不仅仅是认证模块的责任,也是调用方需要警惕的问题。我们当时就吃了盲目信任黑盒的亏,默认它返回什么,我们就信什么。
4. 修复方案与回归验证:一套三层整改的组合拳
4.1 三个候选方案的取舍
定位到根因后,我列了三个修复方向,对应不同的整改深度:
| 方案 | 改动范围 | 优点 | 缺点 |
|---|---|---|---|
| 缓存key加租户维度 | 认证模块内部 | 改动小、见效快 | 只能在缓存层面隔离,无法防住其他共享点 |
| verify返回前做身份二次校验 | 认证模块内部 | 能拦截大部分串号 | 需要额外一次存储查询,性能略有下降 |
| 业务层去除全局上下文依赖 | 业务代码全量 | 根治“上下文错乱”类问题 | 改动范围大,需要业务侧配合改造 |
我最终没有只选一个,而是做了三层组合修复。原因很朴素:缓存key加租户只是堵住了眼前这个洞,二次校验能拦住更多潜在误用,而业务层去除全局上下文依赖,才是治本。
4.2 具体改造步骤
先改缓存key的生成策略,把租户维度加进去。这是最直观的一步,用下面这种格式替换原来的session:{userId}:
// 修复前 public String buildSessionKey(Long userId) { return "session:" + userId; } // 修复后 public String buildSessionKey(Long tenantId, Long userId) { return "session:" + tenantId + ":" + userId; }第二步,在认证模块返回用户上下文之前,做一次身份一致性校验。也就是把“Token里的userId/tenantId”和“缓存里取出来的userId/tenantId”逐个比对,不一致就直接抛异常,并记录告警日志。这一步我特意放在了缓存命中之后、数据返回之前:
// 伪代码:verify接口内的二次校验 UserIdentity cached = cache.get(buildSessionKey(token.getTenantId(), token.getUserId())); if (cached == null) { // 降级查数据库 cached = userRepository.findByTenantAndUser(token.getTenantId(), token.getUserId()); cache.set(buildSessionKey(token.getTenantId(), token.getUserId()), cached); } else { // 关键校验:缓存身份和Token身份必须一致 if (!cached.getTenantId().equals(token.getTenantId()) || !cached.getUserId().equals(token.getUserId())) { log.error("Identity mismatch. token userId={}, tenantId={}, cached userId={}, tenantId={}", token.getUserId(), token.getTenantId(), cached.getUserId(), cached.getTenantId()); throw new AuthException("AUTH_IDENTITY_MISMATCH"); } }第三步也是动作最大的一步:梳理业务代码里对全局上下文的依赖。排查下来发现,至少有十几个接口是直接从“当前请求上下文”里拿用户信息的,而这个上下文本身是黑盒模块塞进去的。我带着业务侧的同学,把这部分改成显式传参:接口方法入参增加currentUser对象,由最外层从 Token 解析后显式传递,不再让内部方法偷偷去读“全局状态”。
这一步的工程量确实不小,但它解决了一个隐藏很深的隐患:即使未来认证模块内部又出了类似问题,业务代码拿到的用户身份也是由我们自己在入口处显式构造的,不会再被黑盒内部的错误状态带偏。
4.3 回归验证:实验重跑,串号消失
改造完成后的第一件事,就是把之前的口令实验重新跑一遍。
结果如下:
- 用户A的Token访问用户A的资源:正常返回,无变化;
- 用户B的Token访问用户A的资源:返回403,符合预期;
- 租户C用户D的Token访问用户A的资源:返回403,并且认证模块日志里出现了
AUTH_IDENTITY_MISMATCH告警; - 并发回归:用100个线程同时模拟不同租户不同用户的请求,连续跑了一小时,数据库和缓存里再没有出现任何一条串号请求。
回归通过之后,我又补了一步旧缓存清理。这一步很容易被忽略,但很重要:如果老的session:10086这类key没有清掉,灰度期间新老逻辑并存,还是可能命中旧key。我写了一个一次性清理脚本,把老格式的 session key 全部扫出来删掉,确保缓存层面不会再有“历史包袱”。
5. 状态共享与隔离问题:一套可以复用的排查方法论
5.1 三种共享状态模型,第三种是重灾区
这次排障让我对“共享状态”这四个字有了更深的体会。后来我在团队内部做了一次小分享,把后端系统里常见的共享状态模型分成三种:
| 模型类型 | 典型例子 | 隔离风险 |
|---|---|---|
| 完全隔离 | 每个租户独享数据库、独立缓存实例 | 低,但成本高 |
| 共享但不可变 | JWT签名、只读配置、静态路由表 | 低,只要没人改写 |
| 共享且可变 | Session缓存、连接池、线程局部变量、全局请求上下文 | 高,出问题基本都在这里 |
我们这次踩中的,就是“共享且可变”叠加“不完整标识”的组合。共享本身不是问题,Redis、连接池本来就是共享的,问题在于共享的维度没有对齐。只要共享存储的key维度,比业务隔离维度少一个关键字段,就必然存在碰撞风险。
用一句话总结就是:凡是用作状态关联的key,维度必须完整覆盖隔离边界。你的隔离边界是租户,key里就必须有租户ID;隔离边界是用户,key里就必须有精确到用户的唯一标识。少一个字段,就是在给未来的串号事故埋雷。
5.2 一套面向“黑盒组件”的隔离问题排查顺序
如果你也遇到类似问题,手头又有一个不了解内部实现的黑盒组件,我建议按这个顺序来排查:
- 确认隔离边界:搞清楚系统需要隔离的维度到底是用户、租户还是更细的粒度,数据模型里的归属关系是怎么建的;
- 构造复现实验:准备两个隔离维度下的真实口令和资源,做交叉请求,把复现步骤固定下来;
- 锁定嫌疑环节:通过日志、链路追踪、缓存监控,看身份解析和数据查询之间到底经过了哪些中间存储;
- 拆解共享点:重点检查全局缓存key的组成、会话上下文的作用域、线程池里是否有ThreadLocal泄漏;
- 最小化修复:先补缓存key维度,再加二次校验兜底,最后再决定要不要动全局上下文结构;
- 回归验证并加监控:把实验重跑一遍,确认越权被拦下,再把实验脚本沉淀成自动化巡检。
这套顺序的核心就是“用实验代替猜测”。黑盒不可怕,可怕的是你不设计实验就盲目打开它,然后被里面一堆无关逻辑带偏。
5.3 把“口令实验”变成常驻的自检武器
最后讲一个我后来坚持做的机制:给每个关键依赖组件配置隔离自检探针。
具体做法很简单:在测试环境放一个定时任务,每天自动跑一遍交叉请求。用租户A的口令访问租户B的资源,用用户X的口令访问用户Y的资源,任何一个请求返回了不该有的数据,立刻告警。这个探针脚本写起来也就是几十行代码,我用 Python 简单写了一个雏形:
import requests import schedule import time PROBES = [ { "name": "cross-tenant", "token": "token_of_tenant_B_user", "url": "https://pre.example.com/api/resource/tenant_A", "expect_status": 403, }, { "name": "cross-user", "token": "token_of_user_B", "url": "https://pre.example.com/api/resource/user_A", "expect_status": 403, }, ] def run_probe(probe): resp = requests.get( probe["url"], headers={"Authorization": "Bearer " + probe["token"]}, timeout=5, ) if resp.status_code == probe["expect_status"]: print(f"PASS: {probe['name']}") return True print(f"FAIL: {probe['name']}, got {resp.status_code}") # 这里接入告警,比如企业微信Webhook或者短信 return False if __name__ == "__main__": for p in PROBES: run_probe(p)这个机制的收益非常直接:每次组件升级、缓存策略调整、会话逻辑重构,只要有人破坏了隔离边界,探针会在几分钟内把问题暴露出来。它把“隔离问题”从一次性的手工排查,变成了日常的自动回归检查。
我自己在这件事里最大的体会是:黑盒并不天然可怕,真正可怕的是把一个黑盒当成白盒来信任,出了事故又不知道从哪里下手。口令实验不一定能立刻拆开黑盒,但一定能帮你快速标定黑盒内部到底哪里出了问题。与其等用户来投诉串号,不如主动备好一组探针口令,隔三差五敲一敲那些“看起来没问题”的组件——往往就是这一敲,能避免你能想象到的最尴尬的线上事故。