
1. 一次线上事故改完口令别人的账号串了进来去年年中我们团队维护的一个多租户SaaS平台突然接到一批用户投诉反馈的内容高度一致我在后台改了登录口令结果没过几分钟别人告诉我“你的账号我登进去了你是不是把密码告诉我了”。我们一开始以为是用户自己泄露了凭据甚至怀疑是撞库攻击但数据摆出来之后发现完全对不上——被“误登”的账号分布在不同企业、不同地域唯一的共同点是他们都恰好在同一时段修改过账号口令。那段时间我们连续排查了三天登录日志干干净净没有任何异常IP也没有绕开验证码的痕迹。越是这样越让人心里发毛系统表现像一个黑盒从外部看一切正常但在某一层逻辑上身份边界已经被穿透了。后来我们决定不猜了直接拿线下环境做了一组口令实验一步一步把黑盒打开最后定位到的问题本质就是共享状态没有做好隔离。这里顺手交代一下背景方便后面的思路能对上号。我们平台的前端是一个标准的三层结构Nginx做网关后端是Java Spring Boot服务Redis存会话与热点数据MySQL存持久化数据。多租户的隔离策略用的是逻辑隔离——所有企业数据在同一个库里通过tenant_id区分。这套方案在数据库层和接口层都做了权限校验所以从上到下得出来的结论一直是“隔离没问题”。但这次事故恰恰说明逻辑隔离在执行链路的某一个环节上出现了裂隙而那个裂隙藏在一个我们都没怎么注意过的共享状态里。所谓共享状态我习惯把它理解成“一栋楼里所有住户都能碰到的公共设施”。缓存是共享的Redis里所有key挂在一起会话是共享的Session数据都存在同一个存储空间静态变量更不用说了整个JVM进程里大家共用一份。这些设施本身没有错但如果设计时少写了一个作用域维度比如少了租户ID、少了用户ID、少了请求ID那不同身份的数据就会在同一个桶里互相覆盖、互相读取。这次口令串号就是这类问题的教科书案例。很多人看到“口令串号”第一反应是加密问题是不是密码哈希撞了是不是弱口令爆破但我们很快排除了这些可能。实验和分析做下来真正的问题出在一个更隐蔽的位置某段业务逻辑为了优化性能把“最后一个验证成功的用户身份”缓存到了一份静态共享存储里后续的异步写操作再从这个共享存储取身份信息。当几个用户几乎同时完成口令修改时这段共享状态就被互相覆盖导致A的修改结果被记到了B的名下。这个定位过程非常像侦探破案而“口令实验”就是我们的侦查手段。下面我会把整条排查链路、黑盒机制、以及最终的修复方案完整写出来。如果你也维护着任何带多租户属性的系统或者写过涉及会话、缓存、静态变量的后端代码建议认真看完尤其是第三节的三种共享状态陷阱每一类都是我实际踩过的。2. 把黑盒当黑盒口令实验怎么一步步缩小排查范围在正式聊实验之前先说我为什么决定用“口令实验”而不是直接翻代码。当时的情况是代码量太大、链路太长并且线上环境访问受限我们手头的日志信息不够建立完整的调用拓扑。面对这种状态最有效的方法反而是把它当黑盒来测——通过外部可观察的输入输出推断内部隐藏的逻辑边界在哪。口令刚好是最理想的探针它同时牵扯密码修改、身份校验、会话刷新三条核心链路任何一个环节出现共享状态污染实验结果就会立刻出现可见的异常。第一组实验是“交叉修改对照”。我们准备了两组测试账号A组属于租户甲B组属于租户乙两组账号互不关联。操作路径非常简单先把A1的密码改成某个随机值紧接着立刻修改B1的密码为另一个随机值然后分别用新旧密码尝试登录。如果系统隔离正常应该严格满足A1用新密码能登录、旧密码不能登录B1同理且两个账号之间完全互不影响。实验结果却出现了诡异一幕A1和B1都只能用B1的新密码登录A1自己设置的新密码反而失效了。这个现象直接指向一个结论——登录校验阶段读取的身份状态被后一个修改动作覆盖了换句话说两个账号的身份校验共享了同一块存储。有了这个方向我们继续做了第二组实验验证共享状态的“存活时间”。修改完A1密码后我们刻意停顿5秒、30秒、60秒再修改B1观察串号现象是否随着时间间隔的拉长而消失。结果很有意思间隔5秒时串号概率极高30秒时显著降低60秒后基本恢复正常。这说明共享状态并非永久驻留它是有生命周期的而且生命周期短到以秒为单位。这个特征让我们把搜索范围缩小到请求级别或短生命周期任务级别的共享区域而不是数据库表或长期缓存。第三组实验用来排除“是会话问题还是校验问题”。我们分别在修改密码后手动清除浏览器Cookie、更换客户端IP再执行登录。如果串号依然发生说明问题不在浏览器会话如果串号消失则说明Session层面被共享了。实验结果在清除Cookie后串号依然存在这就基本排除了传统Session的干扰问题锁定在服务端某个跨请求共享的状态容器。把三次实验结果放到一起已经能画出黑盒内部的粗略结构了存在一个进程内的、短暂存活的、跨用户共享的身份状态容器它在并发或者紧邻的请求场景里产生覆盖。我们的排查目标也从“整个登录系统”缩小为“密码修改链路中负责传递当前用户身份的那段中间状态”。后来我们把这三组实验整理成了一张对照表事实证明这个表对后续的代码复现很有帮助这里也放出来供大家参考实验项操作方式预期结果实际结果结论交叉修改A、B租户两账号连续改密码后互登各自受各自新密码约束B的新密码同时生效于A身份状态共享时间间隔按5s/30s/60s间隔依次改密码互不影响短间隔串号长间隔消失共享状态生命周期短会话排除改密后清Cookie换IP再登录串号消失则问题在Session串号依旧存在问题在服务端共享区这三组实验花了一个下午比起翻代码要快得多而且结论非常有说服力。经验是面对未知系统先做对照实验比先读源码更高效。实验设计不需要复杂关键在于控制变量以及选择探测点——口令这种强身份语义的输入输出天然适合用来测绘系统的身份隔离边界。3. 黑盒揭开一角三种把我坑过的共享状态陷阱定位到“身份状态共享”还不够真正到修复层面我们还得弄清楚它究竟是哪一种共享机制。这里说的“黑盒一角”指的就是我们在代码里找到的那几类看似合理、实则危险的共享状态写法。我按踩坑深度把它们分成三类每一类都附了典型案例和代码风格的描述你可以对照着自己的项目想一想有没有类似隐患。3.1 第一类进程内的全局临时身份存储这是我们这次事故真正的元凶。为了减少数据库查询压力有人设计了一个“最后操作者”缓存组件用户在完成某些高风险操作比如改密后系统把用户身份写进一个进程内的静态Map后续的异步审计日志、通知推送等动作直接从Map里取身份省掉一次用户查询。这个设计在单用户、单线程场景里毫无问题但一旦并发请求进入Map里存的就不是“当前操作者”而是“最后操作者”了。代码逻辑简化后大概长这样// 错误示范全局静态Map临时保存身份 public class CurrentOperatorCache { private static final MapString, Long LAST_OPERATOR new HashMap(); public static void setOperator(Long userId) { LAST_OPERATOR.put(current, userId); } public static Long getOperator() { return LAST_OPERATOR.get(current); } }两个用户同时修改密码时请求A写入userId1001请求B紧接着写入userId1002A的异步任务再去读取身份就读到了1002。随后的密码更新、会话刷新操作全部把1002当成了当前用户最终酿成串号。这类问题的隐蔽性在于它的业务语义是“当前”实现方式却是“最近一次”在低并发或演示环境很难暴露必须在压力环境下才会现出原形。3.2 第二类缓存Key缺少完整作用域第二类坑在缓存设计里很常见。很多系统用Redis做用户维度的数据缓存key形如user:{id}本来没问题但一旦引入租户概念key就务必要携带tenantId。否则不同租户下相同ID的两个用户会共用同一个缓存条目。我们这次虽然没有栽在这上面但排查过程中确实发现了不少类似的写法比如user:1001 role:1001 session:1001这些key如果放在多租户环境里几乎注定会串数据。正确写法至少要包含租户维度user:{tenantId}:{userId} role:{tenantId}:{userId} session:{tenantId}:{userId}这看起来只是加了一个占位符的小改动但它是隔离的物理保障。分布式缓存是天然的共享状态所有key都在同一个全局命名空间里如果你不在业务侧手动加上作用域前缀系统永远不会自动帮你区分两个租户的同ID用户。我见过不止一个项目因为缓存前缀漏了租户ID导致用户头像、昵称、权限表在租户之间互相“串门”。3.3 第三类异步链路里传递了可变共享对象第三种陷阱比前两种更隐蔽它藏在异步编程的细节里。比如用CompletableFuture或者消息队列处理任务时消息体里包含了一个可变对象而这个对象又被某个全局拦截器修改过。主线程修改了对象的属性异步线程再去读取时看到的已经是修改后的值。这类问题表面上是“并发竞争”本质上还是共享状态没有被隔离。我们平台里就出现过类似案例一个MQ消息里封装了UserContext对象理论上每个消息基于自己独立的对象实例出发但发送消息前有一个全局过滤器往UserContext里塞了某些临时标记结果所有消费者拿到的上下文都带了上一个请求的标记。这种跨线程、跨任务的状态传递排查起来非常痛苦因为你根本不知道对象在哪一层被谁改过。解决思路也很简单消息对象和上下文对象必须是不可变immutable或者发送前做深拷贝。三类陷阱总结下来其实都指向同一个教训“不共享”才是唯一可靠的状态管理方式。共享本身没有绝对错误但你必须清楚它在哪里共享、生命周期多长、作用域边界在哪。任何一个环节模糊都等于主动给黑盒留了一扇没上锁的门。4. 隔离这事比你想的更依赖“设计默认值”定位到问题之后修复反而变得简单直接。这里我分三个层面来讲紧急止血、根治修复、以及日常防御。每一层都有对应的操作别只做第一层就急着收工。4.1 紧急止血先停用共享缓存组件止血最快的办法是把出问题的进程内静态Map直接下线所有读身份的逻辑改回从数据库查询或者从当前请求的上下文如ThreadLocal获取。这个改动大概半小时内就能完成可以立刻切断串号的路径。注意这一步不要想着优化性能先恢复正确性性能问题后续再说。4.2 根治修复按作用域重建状态管理根治的方案是把“状态”按作用域收拢到正确的位置。我按优先级列一个修复清单当前用户身份只允许存在于请求上下文Java Web场景就是使用ThreadLocal并在请求结束时统一清理同时禁止任何静态变量或全局Map持有用户身份。所有缓存Key携带完整作用域租户ID、用户ID、功能模块一个都不能少命名模式统一为模块:租户:用户:业务标识。异步消息和事件对象一律不可变如果无法做到不可变至少在发送前深拷贝一份避免引用穿透。日志链路加入traceId这个和隔离关系不大但是排查串联问题的基础设施强烈建议一起做。以我们的场景为例修复后的判断逻辑统一为修改密码时从当前请求上下文中获取用户身份后续异步审计任务通过消息体携带的userId显式传参而不是再去共享容器里取。这样不管同时有多少个改密请求并发执行每个请求的身份数据都互不相干。4.3 日常防御把隔离边界写进验收标准修复代码只是第一步防止未来其他人再“埋雷”还需要把隔离意识变成团队的默认行为。我们现在在新功能上线前会跑一套“状态隔离自检”问题大致有以下几项搜索代码里所有static关键字修饰的可变容器逐个确认用途检查Redis key是否全部携带多租户维度前缀检查所有异步任务入参是否为不可变对象每次发布前跑一遍“双账号并发改密”的回归用例确保串号案例被持续监测。这套自检看起来很基础但实实在在地拦住过好几次潜在事故。尤其是并发场景单靠代码审查很难捕捉到问题因为审查者通常会把代码当作“串行执行”去读而并发环境下的交错覆盖恰恰是审查思维的最大盲区。用实验和用例来验收比人肉审查可靠得多。4.4 写在最后的经验之谈这次事故给我的最大一个转变是我对“隔离”二字的理解——以前觉得隔离是数据库权限、接口鉴权这些“大设计”现在才意识到隔离同样藏在那些不起眼的共享变量、缓存Key、消息对象里。越是基础的地方越容易默认它存在也越容易在这里栽跟头。另外一个体会比较深的点是排查黑盒问题时的思路顺序。如果你也遇到类似的诡异问题我建议先别看代码先设计几个对照实验把现象边界摸清楚再带着结论去代码里找证据。实验给出的信息往往具体得多也比盲目扫代码节省几倍的时间。最后分享一个我们现在仍在沿用的技巧每次改动代码前都问一句“这段数据会被别人碰到吗这个Key在其他维度下会冲突吗”。这五个字不值钱但在关键时刻真的能救命。