同一账号一半请求成功一半报签名错,多半是三个进程在抢一把令牌

一、先问一句:这个故障会在服务之间换位置吗
一份公开踩坑记录把这种故障描述得很准:不是稳定复现,而是交替出现。这个区分值得当成排查的第一问——这个故障会在服务之间换位置吗。
确定性缺陷不会换位置。少配一个参数、证书链没装全、出口地址不在白名单里,这类问题的表现与发起方绑定,哪个副本缺就是一直缺。只有多个进程在争同一份单实例状态时,胜者才会随时间互换:某个服务刚把凭证取到手,另一个服务的刷新周期到点又取了一次,前一份就在缓冲期走完后失效。日志上看到的就是同一时刻一半成功一半报签名或鉴权错,过一段时间互换。
两个常见误读要提前挡掉。读成负载不均或某个副本配置漏了,于是对齐副本、查环境变量,查完一切正常而故障还在;读成上游不稳定,于是把超时调长、把重试调大——单活语义下这两件事都是反作用,超时放宽抢线窗口,重试提高踢下线的频率。所以排查要倒过来做,先数进程再查配置:这个账号在这个环境里,有几条代码路径会去发起获取凭证。注意是路径不是实例,一个服务三个副本算一条路径,但每条路径都自己去取,仍然有多份凭证在互相顶。这个数字要写进架构文档并跟着变更维护,写不出来就只能上线后从日志反推,而反推的成本是一次生产鉴权抖动。
二、接入前把令牌语义确认清楚:单活、多活与限次
同一账号能同时持有几份有效凭证,是接入方案的分岔点,而平台文档常常不写这一层,或者只留一句含糊说明。三类语义要分开对待。
单活:第二次获取会让第一次那份在一段缓冲期之后失效,任何二次获取都是对现有使用者的破坏,唯一正确形状是全局一个持有者。多活:多份凭证可同时有效,各自持有不会互相顶掉,剩下的问题只有配额与轮换。限次:获取动作按次数或频率受限,并发获取会耗尽配额,表象同样是鉴权失败,很容易被判成凭证本身出了问题。
三类对应三套接入方案,猜错一类的代价是生产上偶发且难归因,所以要用一次受控实验测出语义,而不是从文档里读一个假设。实验固定四步。第一步隔离:在独立环境用一个与生产不同源的身份或专门的子账号,不要在生产账号上做并发实验,因为实验动作本身就是踢下线。第二步:同一身份并发发起两次获取,记录两次返回值是否相同。第三步:观察第一次那份是否失效、多久之后失效,缓冲期长度要实测,不要假设成零。第四步:把结论连同测法写进内部文档,注明适用的环境与账号,环境或策略变更后重测。
第三步最容易被省掉,而它恰好解释了“偶尔又好了”这个现象:缓冲期内旧凭证仍然可用,被顶掉的一方不是持续失败,而是等缓冲期走完才失败,看日志的人会把它读成网络抖动。顺带一条推论:任何“先取新的再停旧的”这类交叠逻辑都不成立,那段窗口是平台给的。
三、把凭证当单实例资源:一个持有者加多个消费者
接受凭证是单实例资源之后,架构形状就自然定了:一个持有者负责获取与刷新,多个消费者只读用。三条约束要同时成立。全局唯一刷新者,即一个中控服务,只有它持有获取凭证那条链路。其它服务一律通过内部接口取用,取用接口自带缓存与提前刷新,消费者要的是“给我一份可用的”,不是“帮我取一份”。消费者永远不直接触达上游的获取接口。第三条要在代码层落实而不是靠评审提醒:上游客户端只在中控那一个包里可见,其它模块的依赖图里没有它;别的服务里一旦出现获取这个方法,无论有没有被调用都算破坏。
提前刷新要按有效期的比例判定,不要按固定剩余时长。那份记录给出的口径是已发出时间超过有效期八成即触发刷新。比例在两种情况下都成立:有效期是一个较长值时刷新点跟着后移,上游把有效期缩短时刷新点跟着前移。固定剩余时长会退化:有效期比固定值略长,就变成临到过期才刷,请求恰好落在临界点上失败;比它短,就会出现取到的凭证已经不可用。这类退化在监控上表现为周期性的失败尖峰,很像随机故障。
双检锁在这里防的不是死锁,而是自己造出来的刷新风暴。多个请求同时到达取用接口、同时判定该刷了,各自发起一次,一次并发就产出多份凭证,后一份把刚发给别人的那份顶掉。写法是先无锁读缓存判断新鲜度,判定需要刷新才进锁,进锁后再判一次,只有仍需要的那个执行刷新,其余等待方直接复用结果。
刷新失败必须显式回调,而不是在中控里静默重试。失败意味着凭证本身或配额出了问题,两类原因都需要人介入;静默重试会把一次配置错误演变成持续的上游请求放大,还把唯一的告警机会吃掉。回调做两件事:发一条告警,把自动刷新切到停止状态,等人工确认再恢复。
重试上限的形状取一次刷新加一次重试:它覆盖了最常见的根因即凭证已失效,又不会把故障放大。两条配套细节:强制刷新的标志只在重试那一轮传入,第一轮不带;重试循环写两轮,且只有第一轮遇到鉴权失败时才继续。理由是多数失败是中控缓存里那份已被废掉而中控不知道,带标志重取一次就能恢复;第二轮还失败就不是凭证问题,继续循环只会掩盖原因。
四、五行对照:五类做法、改法与不修的后果
那份记录给了五行对照,值得原样搬进交付检查,我们按自己的口径重排成表。
| 做法 | 改法 | 不修的后果 |
|---|---|---|
| 凭证硬编码在配置文件或代码库里 | 改用环境变量或专用密钥管理服务 | 暴露面扩大,轮换时改不动 |
| 每个业务服务自持一份凭证 | 只由一个中控服务持有与刷新 | 服务之间互相把对方踢下线,表现为交替失败 |
| 各服务按自己的定时任务刷新 | 刷新集中到中控调度,并配失败回调 | 刷新风暴与配额耗尽 |
| 沙箱与生产共用同一份配置模板 | 启动时校验凭证指纹,不匹配直接拒绝启动 | 有人在沙箱刷一次,生产就抖一次 |
| 对鉴权失败无限重试 | 最多一次刷新加一次重试,之后告警 | 故障被放大,下游看起来像流量突增 |
第二行是症状级,第三行是机制级,第四五行是防线级,第一行是长期欠账。修复顺序也就出来了:先解决持有者数量,再解决调度归属,最后补环境校验与重试上限。第一行与这条故障往往无直接关系,却会在同一次排查里被顺带发现,要一并处理。
五、日志与环境:脱敏的写法与指纹校验的落点
日志里只打凭证哈希的前八位,不打明文。价值在关联:三个服务在同一时刻报鉴权错,把各自记录的前缀一比对,就能判断它们用的是不是同一份,或者是否有一方拿着早就被废掉的旧凭证。前缀够用于比对,又不把凭证本身交出去;明文一旦进入日志,等于把凭证交给日志系统的全部读取方,采集、转发、索引、排障脚本每一环都在扩大暴露面。也要写清一条限制:前缀位数少会撞,不能当唯一键,只能做时间窗口内的辅助比对。
另有四个更常被漏的位置:异常消息拼上请求上下文时把凭证带进去、埋点属性、任务队列的消息体、调试接口返回的完整配置。这四处要在同一次改造里统一过滤,而不是只改日志一处。配套一条纪律:任何把凭证长期写进第三方存储的路径都要拒绝。把凭证存在队列里等后续任务复用,看着省了一次刷新,实际是把单实例资源变成可复制的状态,之后所有排查都会失去依据。
环境层的坑是配置模板共用。沙箱与生产用同一份模板,只在两处填不同值,很容易把生产凭证带进沙箱;一旦有人在沙箱跑一次完整流程,刷新动作就把生产那份顶掉,而生产侧只看到签名错误。启动时校验凭证指纹比事后排查便宜得多:启动时对凭证算一次哈希并与该环境的预期值比对,不匹配就拒绝启动,日志里说明是哪一项不匹配。三条细节要一起定:预期值放在环境元数据里而不是代码里,否则轮换凭证还得发一次版本;不匹配时打印环境名与哈希前缀,不打印凭证本体;把轮换写成一个流程而不是一个动作,因为指纹要跟着更新,两件事分开做一定会漏。
六、四形态、配额预算与六项清单
单活语义下的竞争来源通常四类。多实例部署,每个副本各持一份,副本数一多就变成几方轮流抢。定时任务与 Web 进程并存,任务进程独立跑一份刷新逻辑,平时没人碰它,到周期就把在线服务顶一次。灰度期间新旧版本同时跑,两套代码各留一份刷新逻辑,互相顶。本地调试连生产账号,本机跑一次就是一次真实获取,这也是只在白天出问题这类怪现象的主要来源:它和人的作息相关而与系统周期无关,监控上几乎无法归因。
前三种的处置是同一句话:所有副本与所有进程走同一个中控,不允许在任务进程或旧版本里留第二条获取路径。第四种落在人与流程上:本地调试一律用沙箱身份,凭证与生产不同源;同时在生产侧把来源不在集群内的获取请求单列成一个计数上报,让这类事件在监控上可见。
再补一条容量提醒:刷新调用要计入对上游的请求配额预算,当成一个单独的量来看。配额耗尽的表象常常就是鉴权失败,不在预算里就会把容量问题误判成凭证问题,然后做一轮没有结果的排查。中控集中后刷新频率本来很低,一旦有人绕过就会陡升,这个计数因此还能当绕过检测的副指标。
给客户的凭证检查清单是六项,每项都要有可判定的说法:令牌语义是否经过受控实验并记录在案;同一环境下持有与刷新的服务数量是否为一;提前刷新是按比例还是按固定值;刷新失败是否走显式回调并会停止自动刷新;日志与异常路径里是否存在凭证明文、脱敏是否收到同一个方法里;启动期是否做环境指纹校验并且真能拒绝启动。任何一项答不上来,就等于没有检查过。
洹拓在交付长链路项目时把这张清单放在上线前的固定位置,与依赖清单、回退方案并列。那份记录留了一句结论,适合贴在排查手册首页:对长链路系统,凭证管理往往是第一个瓶颈,真正要问的是有多少个进程在同时做同一件事。