首页 / 资讯中心 / 文章详情

并发下口令验证为何串号?共享状态与线程隔离实战

并发下口令验证为何串号?共享状态与线程隔离实战 ★ FEATURED ARTICLE
调用这个接口之前我自认为对认证系统里那点并发套路早就门儿清数据库事务、锁、缓存失效每一样都能讲出个一二三。结果一场口令验证实验直接把我的自信按在地上磨——在并发场景下反复出现“旧密码还能登录”、“新密码反而登不上”甚至偶尔出现用户身份串号吓得我赶紧检查是不是哪里泄漏了会话。业务层信誓旦旦告诉我一切正常可底层那个看不见的黑盒里一份共享状态早就悄悄在多个线程之间串了线。这篇文章不绕弯子直接记录这场口令实验从设计到踩坑、从复现到定位的完整过程聊聊共享状态是怎么破坏隔离性的、为什么成熟框架也会中招以及我用了哪些手段把问题一点点揪出来。如果你正在写登录、会话、权限这类“天生有状态”的服务或者单纯想看看多线程下那些看不见的内部状态到底有多能折腾人这篇应该能省你不少排查时间。1. 一场口令实验我想验证什么1.1 实验设计与预期事情起源于一次平平无奇的功能加固。产品要求确认两条硬性规则第一用户修改密码后旧密码必须立即失效不能允许“换密码后旧密码还能登录”这类灰色窗口第二不同用户之间的口令状态必须完全隔离任何情况下都不能出现A用户的口令影响到B用户验证的情况。乍一听这需求很简单但考虑到线上有大量并发请求我决定不只在单线程里跑测试而是直接写一个多线程压测脚本模拟真实认证服务里“改密—登录—改密”的高频交错。实验环境大概是这样JDK 11Spring Boot 2.7MySQL 5.7认证模块用BCrypt做口令哈希上面还套了一层自定义的内存缓存用户上下文用ThreadLocal暂存。压测脚本自己控制线程池不跑JMeter因为我想精确控制每个线程的执行序列。核心流程是这样的每个线程随机挑一个测试用户先设置一个新密码紧接着立刻拿旧密码登录断言必须失败然后拿新密码登录断言必须成功最后再随机挑另一个用户用那个用户的口令尝试登录当前用户断言必须失败。1.2 诡异现象清单线程数从10往上加加到50的时候坏事了。第一次跑出断言失败率3%左右我一开始以为是数据库事务没提交导致的“读到旧快照”可后来把日志时间线和数据库binlog拉出来对发现事务边界完全正常。真正诡异的是下面这几类现象旧密码登录偶发成功。设置新密码的请求明明返回了成功紧接着的旧密码登录却验证通过了仿佛密码从未被修改过。新密码登录偶发失败。反过来更离谱——新密码是对的却频繁报“用户名或口令错误”。身份串号。A用户设置密码后用A的密码尝试登录B用户居然在某几次请求里验证成功像是认证中间件把两个用户的密码哈希搞混了。这类问题有个共同特征出现的概率随并发量非线性变化10线程时几乎不出现50线程时稳定复现100线程时反而降低。我当时心里就咯噔一下——这不像是单纯的事务问题更像是某个多线程共享的可变状态在被争用。2. 黑盒里的共享状态为什么隔离会失效2.1 “共享状态”到底指什么共享状态这个概念写并发程序的人天天挂在嘴边但落到实际代码里很多人会忽略一个事实它不止是你自己在代码里写的那几个static变量。所有被多个线程同时访问并且允许修改的内存区域包括框架内部、类库内部、容器内部的隐藏数据都属于共享状态。区别只在于你自己写的共享状态出了问题你能在代码评审时一眼看出来藏在框架和类库里的共享状态出了问题时你面对的就是一个不想让你看到内部实现的黑盒。拿口令验证这个场景来说表面上看每个请求都经过“控制器—服务—加密器—数据库”这条独立链路好像谁也不碰谁的数据。但实际上认证服务往往要维护密码策略配置、失败计数、锁定时间、哈希缓存这类跨请求的数据这些就是最典型的共享状态。一旦多个请求同时读写同一份状态又缺少正确的同步或隔离机制就会出现我在实验里看到的那种互相污染的诡异现象。2.2 三个最容易藏共享状态的角落我在排查过程中把注意力逐步收敛到三个具体位置后来发现这基本覆盖了认证系统里90%的共享状态问题。第一个位置是缓存层。密码哈希缓存如果设计成ConcurrentHashMapuserId, passwordHash在多线程下有一个很阴险的竞态窗口线程A更新密码先执行map.put(userId, newHash)但就在put之前线程B读取了旧值于是后续用旧哈希去校验新请求。这里的问题不光是“旧密码还能用”这种业务事故更隐蔽的是如果put方法在扩容期间被并发访问某些版本的实现甚至可能丢失数据或者读到不完整的半初始化状态直接导致身份串号。第二个位置是ThreadLocal。ThreadLocal本身是隔离工具它的设计初衷就是让每个线程拥有独立的变量副本。问题在于它和线程池组合使用时会出现记忆错乱线程执行完任务A后ThreadLocal里的值如果没有被清理线程被线程池回收并复用给任务B时任务B一开始就会读到任务A留下的数据。我的实验里有一个现象特别明显——用户B的登录请求在某些线程上居然能读到用户A设置过的上下文原因就在这。第三个位置是全局随机源。口令哈希需要生成盐值盐值来自SecureRandom。SecureRandom本身是线程安全的但它背后可能依赖操作系统的熵源多个线程同时大量请求随机数时会出现熵源阻塞或者性能急剧下降。阻塞期间其他线程的认证请求全部卡住表现出超时或者失败率飙升看起来就像口令状态出了岔子实际是黑盒里的共享熵源被挤爆了。其实还有一个老生常谈的坑虽然不在我的实验里直接出现但我排查时顺手检查过——共享的SimpleDateFormat或者Calendar对象。JDK 8之前SimpleDateFormat内部用了一个可变的Calendar实例多线程并发调用format或parse时会拿到错乱的时间如果代码里用它记录密码锁定时间锁定时长会出现几十秒甚至几分钟的随机波动表现就是用户明明被锁了却偶尔能登录成功。2.3 为什么成熟框架也避免不了有人会问框架不是早就处理过线程安全了吗为什么还会踩中这种问题答案是框架能保证它自己公开API的线程安全但管不住使用者在框架的“缝里”塞进去的共享状态。比如Spring的Bean默认是单例这个Bean内部如果有任何可变的实例字段在多线程请求下就是共享的框架不会拦着你往单例里放一个Map也不会替你在请求结束后清掉线程池环境里的ThreadLocal。更麻烦的是有些共享状态以“优化”为名被加入黑盒。比如某些加密库为了减少随机数的生成开销会在内部缓存一份盐值种子或会话密钥这些种子在极端并发下如果被多个线程复用生成的盐值可能会出现碰撞导致两个用户得到完全相同的口令哈希参数。这种东西从外面看就是一个普通方法调用一句文档都不会提到内部存在共享缓冲。3. 定位真相从现象到根因的三步排查3.1 第一步用最小实验复现遇到这类偶发问题第一反应不是去翻源码而是想办法把它变成“必现问题”。复现思路是逐步缩小变量范围先把线程数固定在大概率出错的50然后分别停用缓存、关掉ThreadLocal、替换随机源看现象会不会消失。这个过程本质上是在黑盒上戳洞——每关掉一个变量现象消失或者保留就能把这个变量的嫌疑排除或锁定。实测下来关掉密码哈希缓存之后“旧密码偶发成功”的现象彻底消失而“身份串号”仍然偶发。这说明缓存层只解释了第一个现象串号另有来源。继续关掉ThreadLocal之后串号也消失了。到这里两个核心症状分别对应到了两个共享状态随机源的嫌疑被排除——虽然它在高并发下的确会让请求变慢但并没有直接造成验证结果错误。这里有个值得强调的细节复现实验一定要引入断言。很多人压测只统计TPS和错误率但如果一个错误响应背后的业务逻辑是“旧密码验证通过”这个错误在压测工具的眼里可能就是一次正常的200响应。我自己写脚本时给每个步骤都加了断言这种断言才是发现功能正确性问题的关键。3.2 第二步让日志替黑盒开口复现问题只是第一步要定位到具体代码行必须让日志帮我们还原每个请求的时间线。我用的办法比较笨但非常有效在认证链路的每一个关键环节都打日志包括控制器入口、缓存命中与否、ThreadLocal的读写、BCrypt验证开始与结束、数据库更新语句每一条日志都带上线程ID、用户ID和时间戳。在50线程并发下收集几十条出错请求的日志很快就看出了规律。缓存命中日志显示旧密码登录请求的线程在时间上恰好落后于改密线程几十毫秒但它在缓存里读到的还是改密前的旧哈希。顺着这个时间关系去看代码发现缓存更新用的是普通的put操作没有使用原子化的compute或putIfAbsent更没有版本号校验改密和读缓存之间存在明确的竞态窗口。ThreadLocal那边更有意思。日志里能看到线程ID完全一致但用户ID从A变成了B。这说明同一个线程先处理了A的请求ThreadLocal里放进了A的上下文任务结束后没有清理线程池复用这个线程处理B的请求时业务代码直接顺着ThreadLocal拿到了A的名字、密码哈希和会话标记。这种在日志里直接看线程ID接续关系的方式比任何静态分析工具都直观得多。3.3 第三步线程转储与代码审计日志展示了现象但线程转储能让我们看到黑盒内部某瞬间的“现场照片”。我在复现出串号的那几秒里连续抓了几轮jstack重点看两类线程一类是正在执行认证逻辑的Tomcat工作线程一类是执行缓存清理或者数据库访问的线程。jstack的输出配合日志能确认哪些线程正在访问同一个缓存对象、哪些线程正卡在某个锁上。代码审计阶段我重点审查了认证服务这个单例Bean里的所有字段。审计清单其实就几句话每一个实例字段问它三个问题——这个字段会被哪些线程读写它是否需要跨请求保留如果请求并发访问它谁是最后写入的人后来的请求会不会读到别人的数据按这个清单过了一遍问题就集中到了两个字段上一个是缓存Map一个是ThreadLocal和实验复现的结论完全吻合。3.4 根因复盘整个根因链条可以概括成一句话业务层向框架的“缝隙”里塞了两份共享状态一份是未加原子控制的缓存哈希一份是没有清理机制的ThreadLocal上下文它们分别破坏了“密码更新即时性”和“用户身份隔离性”。框架本身没有错它提供了线程安全的ConcurrentHashMap和独立的ThreadLocal是我们用错了方式——一个用了朴素的put一个忘了解除绑定。4. 隔离方案四种状态管理策略4.1 不可变状态处理共享状态的第一优先级是让它不可变。如果一个对象一经创建就不再修改那它天然就是线程安全的不需要任何锁和同步。在口令场景里密码策略配置是最适合做成不可变对象的——最小长度、大小写要求、特殊字符要求、历史口令保留数量这些内容在应用启动时加载运行时不应该被修改。我把PasswordPolicy这个类改成了final类所有字段都用final修饰构造函数里一次性完成赋值只提供getter。之前代码里某个接口居然允许在运行时动态修改最小长度改完之后整个策略对象就彻底安静了再也不用担心并发读一个正在被修改的字段读到奇怪的中间值。4.2 线程封闭与ThreadLocalThreadLocal是“线程封闭”思想的典型工具它确实能把每个线程的状态隔离开来但前提是使用者必须负责清理。对于线程池环境我总结了一条铁律凡是用ThreadLocal存放跟单个请求相关的上下文必须在finally块里调用remove而且要放在最外层入口处统一处理。我在实验代码里踩的坑就是因为原来某段代码只做set根本没有remove线程池一复用就串号。正确写法其实不复杂关键是养成肌肉记忆private static final ThreadLocalAuthContext CONTEXT new ThreadLocal(); public void handleRequest(UserRequest request) { try { CONTEXT.set(AuthContext.of(request.getUserId())); // 整个业务处理过程 authService.process(); } finally { CONTEXT.remove(); } }这个写法有个隐藏好处即使业务处理内部发生了异常finally也会执行清理不会让脏上下文在池化线程上残留。大家写类似代码时建议把set和remove放在同一个方法里不要把remove交给其他过滤器或拦截器——一旦某个中间件顺序调整清理就会漏掉。4.3 加锁与原子操作有些状态天生需要跨请求保留比如密码哈希缓存和失败计数这时候不可变和线程封闭都做不到只能走同步这条路。同步有两个层次简单场景用原子类复杂场景用锁。失败计数适合用AtomicInteger用incrementAndGet和compareAndSet来保证原子增减。密码哈希缓存则需要原子化的更新逻辑。我原来直接写map.put(userId, newHash)这就是问题的根源。改成ConcurrentHashMap的compute方法后读改写操作在同一个锁粒度内完成不会再出现其他线程读到旧值的问题passwordCache.compute(userId, (key, oldHash) - { if (oldHash ! null oldHash.equals(oldPasswordHash)) { throw new InvalidPasswordException(新口令不能与旧口令相同); } return newHash; });compute方法的好处是回调内的整个操作对于这个key是原子的而且如果回调抛出异常key对应的映射不会有任何变化。不过有一点要注意compute里不要做耗时操作比如访问数据库、远程调用否则锁的持有时间过长会拖垮并发性能。我实测过在回调里加一次数据库查询50线程下吞吐量直接掉了一半后来把数据库操作挪到compute之前情况才缓解。锁的使用原则其实就一条尽量缩小临界区范围。能用原子类解决就不用synchronized能用细粒度锁就不用全局锁。口令场景里的缓存锁天然按userId分散不应该设计成一个全局锁来锁所有用户的密码操作。4.4 无共享与请求上下文传递这是最彻底的隔离方案干脆不共享。具体做法是把和请求相关的数据显式地通过方法参数传递而不是放在任何一个全局可访问的地方。比如认证服务里需要用户ID那就把它作为参数一路传下去authService.authenticate(userId, rawPassword);这种风格最大的好处是可测试性和可读性都不错代码里没有“隐藏的当前用户”每个方法的依赖一目了然。缺点也很明显如果链路很深参数会变得很长很多方法签名都要改。折中方案是用请求作用域的上下文对象Spring里可以借助RequestContextHolder或者在Filter里创建一个requestScope的Bean让容器在请求结束自动销毁不需要手动清理。我个人的建议是核心认证链路尽量走参数传递辅链路比如日志、审计可以用MDC来携带上下文信息。MDC本身是日志框架提供的线程本地变量配合线程池时同样存在清理问题但它通常只影响日志输出内容不会直接影响业务正确性容错空间大一些。5. 常见问题速查与避坑清单5.1 症状对照表把这次实验以及前后排查中遇到的各种情况整理成一张速查表以后遇到类似现象可以按图索骥。症状可能的共享状态快速确认方法修复思路修改密码后旧密码偶尔还能登录密码哈希缓存更新存在竞态增加缓存命中与更新时间戳日志使用compute/putIfAbsent或引入版本号不同用户之间身份串号ThreadLocal在线程池中残留日志里对比threadId与userIdfinally块中remove或用参数传递偶发时间错乱导致锁定失效共享SimpleDateFormat/Calendar打印格式化前后的时间戳使用DateTimeFormatter或ThreadLocal高并发下验证请求整体变慢全局SecureRandom熵源阻塞jstack查看线程栈改用独立随机源或预生成盐值新密码偶尔验证失败数据库事务隔离级别或缓存穿透检查事务日志与binlog明确事务边界修复缓存失效策略这张表里每一条我都实际见过其中共享SimpleDateFormat那类问题虽然这次实验中没占据主角位置但排查时顺手在一个老模块里发现过当时的现象是锁定时长随机浮动用户反馈“有时候密码连续输错三次也不锁”。排查了很久才发现是日期格式化对象被并发污染。5.2 实操心得排查这类共享状态问题有几个经验值得单独拿出来说。第一个心得是检查单例Bean的可变字段时不要只看setter。害人的状态往往不是通过setter修改的而是通过一个public方法里的内部逻辑比如“更新密码成功后就往缓存里塞一条记录”。这种隐式写入最难被代码审查发现我把它们叫做“写入副作用”。所以审查时更有效的做法是找出单例Bean里每一个非final字段然后看它出现在哪些方法里只要有读和写同时存在就标记为疑似共享状态。第二个心得是压测一定要分梯度。不要一上来就压100线程那样只能得到一个整体成功率看不出问题随并发量变化的规律。我这次是10、30、50、80、100五个梯度逐个跑每个梯度跑三遍记录失败率、失败类型和时间线。共享状态导致的bug有个共同特征——失败率和并发量之间的关系往往是“先升后降”的非线性曲线因为线程数继续增加后调度器让竞态窗口错开的概率反而变大。这种非线性特征本身就是重要的诊断信号。第三个心得是日志是黑盒的探针但探针必须插在关键位置。插日志不能只在入口和出口否则只能看到请求进来、请求出错看不到中间状态。这次实验里最有价值的日志是“缓存读取前”和“缓存读取后”的两行它们配合时间戳精确还原了竞态窗口。另外线上环境不要开这种级别的日志但可以在压测环境开一个独立的日志级别开关方便复现时快速抓取现场。第四个心得是关于BCrypt这类底层库的不要把密码验证和密码更新放在同一个同步块里也不要在循环里调用verify。否则一次高强度压测会触发大量全量哈希计算CPU全部烧在选择盐值和密钥扩展上其他正常请求全部排队。这也是黑盒共享资源的另一种形式——资源争用。后来我在压测脚本里加了一个简单统计发现一次BCrypt验证平均耗时约100毫秒50线程同时跑时CPU队列直接拉满这个数据反过来让我相信随机源阻塞的判断不是空穴来风。6. 结尾那次实验后我养成的习惯经历了这场口令实验之后我写认证类代码时的习惯彻底变了。以前我拿到需求先画业务流程图现在我多了一道工序先把运行时的状态图画出来。哪个对象是单例、它里面有哪些字段、字段在哪些请求路径上会被读写、线程池环境里哪些位置会遗留数据这几行问题画完很多坑当场就现形了。配套的动作也变成了硬规矩涉及密码、令牌、会话的代码改动之后必须跑一次多线程断言压测不光是看TPS还要看业务规则有没有被并发破坏。我还在压测脚本里保留了一条“随机用户串号断言”专门用来探测身份隔离是否失效这条断言救过我一次上线前揪出了一个缓存版本号没更新的隐患。最后分享一个排查共享状态问题的小技巧虽然简单但非常管用遇到偶发错误时先把并发数打成一只“双人成行”的规模——两个线程、一个共享目标、无限循环。很多竞态在几十个线程下会被无数干扰因素淹没反而在两个线程的极端对称竞争下最容易稳定复现。我这次实验里的ThreadLocal串号最后就是用一个两线程循环复现得干干净净的。下一次你也遇到“哎这个bug怎么时而有时而没有”时试着把这个最复杂的问题还原成两个线程之间的简单竞争往往比加更多并发量更快看到真相。
阅读完成 · 觉得有帮助?
咨询建站