工程观察
收录于 工程治理体系
跨微服务需求该怎么接:从一次分布式锁失效说起
一次租户级灰度需求做到一半,撞上了分布式锁失效。我后来才意识到,真正麻烦的不是灰度本身,而是这类跨微服务需求不该再按单点功能开发去接。
最近我接了一个租户级灰度需求。
一开始我真觉得它不大。系统里本来就有按比例、按用户、按来源做分流的能力,现在只是多加一个“按租户”。如果只看单个服务,这件事甚至很像补一个条件判断,再把租户标识沿调用路径透出去。
但做到中途,撞上了一个很具体的 bug:灰度一接进去,原来默认能兜住并发的那层分布式锁失效了。
那一刻我才反应过来,我卡住的不是灰度规则本身。问题是这个需求一旦跨服务,原来很多默认前提会一起被带动:请求会落到哪套服务,上下文能不能一路带过去,锁、幂等、补偿还算不算同一套约束。继续把它当成某个服务补个判断,后面只会越做越乱。
这篇不展开灰度实现细节,我更想把另一件事写清楚:这种跨微服务需求到底该怎么接,做到什么程度才算做完。
一、这类需求最容易被错接成“一个服务补一段逻辑”
租户级灰度表面上只是多了一个判断维度,但它一旦生效,改掉的往往不止某个函数分支——是整个流程里一批默认前提。
比如:
- 谁来识别这个请求是不是灰度租户
- 这个标识会不会在同步调用和异步链路里都带上
- 哪些服务要按这个标识改行为,哪些服务不能改
- 原来按单一路径成立的缓存、锁、幂等、补偿逻辑还成不成立
这些东西平时都散在系统里,不会写在同一个地方。也正因为这样,需求刚接进来的时候很容易被错看成一个局部开发任务:仿佛只要把入口规则补上,后面就是验证细一点的问题。
可一旦一个需求会改调用链里的共享前提,它就已经超出“某个服务加功能”的范畴——它在改的是整个流程怎么一起工作。
以前很多验证动作之所以还能做,是因为线上默认只有一套服务在接这类请求。请求往哪走、共享状态被谁改、互斥边界落在哪,基本都是稳定的。租户级灰度一进来,这个前提就没了。线上会同时有普通服务和灰度服务,租户不同,落点不同,后面带出来的上下文和共享状态也可能不同。
这时候你要验证的,已经不只是“代码里这个判断有没有写对”——还要看新旧服务并存以后,哪些租户、哪些路径、哪些共享状态会开始互相影响。
这类误判我后来盯得很紧,因为接法一旦错了,后面的责任也会跟着歪。你本来只是在改一段能力,最后却会被推着去为从头到尾的正确性背书。
二、分布式锁失效这件事,让我意识到旧前提已经没了
这次把我打醒的,就是那个分布式锁问题。
灰度接入之前,我默认这类请求在整个平台里只会落到一套服务节点上。也因为这个前提成立,原来的锁键、加锁位置、持锁边界,都是按“只有这一边会处理这类请求”写的。
灰度接进来以后,线上同时有普通服务和灰度服务,这个前提就没了。
灰度租户会打到灰度服务,普通租户还在原来的服务上跑。表面看只是入口分流了,实际更麻烦的是,后面很多东西还是共用的:同一份业务状态、同一套锁、同一套幂等约束、同一批异步任务。于是灰度服务上的动作,可能会直接影响正常服务那边的处理结果。
这时候你再回头看那把锁,会发现它没有“突然坏了”——它原来依赖的互斥边界已经被改掉了。锁还是那把锁,但系统已经变了,不再是“全平台只有一套处理入口”的状态了。
排查到这一步,我才意识到这次碰到的不是一个 bug。
这件事对我最重要的提醒,与其说是“锁要重写”,不如说是以后再接这种需求时,不能只问逻辑加在哪。要先问清楚:
- 哪些共享状态原来默认只会被一套服务改
- 哪些锁、幂等、补偿、缓存是按“只有一个处理入口”写的
- 新旧服务并存以后,哪些操作会互相影响
如果这些问题不先盘出来,后面撞上的就不只是一个 bug——会是一串你原本没意识到已经被改掉的系统前提。
三、后来我接这类需求,会先把对象盘出来
我现在再接跨微服务需求,第一步已经从拆开发任务,变成先看这次到底动了哪些对象。
我现在会先把“验证对象”缩出来,不再笼统地说“把这个需求验完”。因为实际会变的,一般就是几类很具体的东西:
- 请求最后会落到哪套服务
- 灰度标识和上下文能不能一路带到后续调用
- 哪些共享状态会被新旧服务同时读写
- 哪些锁、幂等、补偿、缓存边界会被打穿
- 出问题时灰度、观测、回滚到底按什么维度切
这些东西不先盘出来,后面就很容易陷进一种假忙:case 越拉越长,但你其实还是不知道自己到底在防什么。
对象盘出来只是第一步。再往下至少还得接两件事:先做影响分析,把不受影响的部分尽快排出去;再把验证拆成机制层和业务层,不要糊成一句“把这个需求验完”。
1. 先看上下文和决策点
也就是这次改动到底靠什么决定行为。
是租户标识,还是某个开关,还是区域、版本、产品形态?
这个决策点会在哪些服务里被消费?同步调用会带,异步任务会不会带,重试、补偿、人工操作路径会不会带?
如果连“谁来识别、谁来消费、谁可能丢失”都说不清,后面的验证基本一定会发散。
2. 再做影响分析,把不受影响的部分尽快排出去
这一步我以前也很容易忽略,总觉得先把所有可能受影响的地方都拉进来更稳。后来我发现,跨微服务需求如果不先排除,验证范围只会越滚越大。
并非所有经过调用路径的服务都会改行为,也不是所有租户、所有配置、所有部署形态都会真的触发新旧差异。有些服务只是透传上下文,有些路径虽然被经过,但业务结果根本不变,有些客户环境压根不会命中这次灰度条件。
所以我现在会很刻意地先排:
- 哪些服务只是经过,但行为不变
- 哪些租户和配置根本不会触发差异
- 哪些产品形态和部署环境这次其实不受影响
越早把这些“不受影响”的部分排出去,后面的验证空间才降得下来。不然你会很容易把精力花在一堆其实不会出事的地方,高风险的点反而被淹掉。
3. 再看哪些状态和互斥是按旧世界写的
这是我这次吃亏以后最先会看的地方。
像分布式锁、幂等键、状态机迁移、补偿触发、缓存命中条件,这些东西很少会自己声明“我依赖单一路径”。但它们很多时候就是按这个前提写的。
我现在会反过来问:
- 哪些状态判断默认只有一套行为
- 哪些互斥关系默认整个流程都认同
- 哪些地方一旦新旧逻辑并存,就可能出现空窗或者重叠
这一步不做,后面越验证越像补洞。
4. 再看那些平时不在主流程里、但出事时一定绕不过去的链路
麻烦的地方,很多都不在主请求里。
比如异步任务、延迟消费、重试、回调、缓存刷新、定时任务、补数据脚本。主流程能跑通,不代表这些地方也跟着一起成立。相反,很多线上问题最后都是从这些“平时不太显眼”的地方钻出来的。
所以我现在不太信“主流程走通就差不多了”这种感觉。跨微服务需求只要动到行为前提,这些外围路径就必须被算进需求范围。
5. 最后看出事以后靠什么收
也就是这次改动一旦有问题,靠什么尽快发现,靠什么尽快停住。
灰度单位怎么切,观测能不能区分新旧路径,日志里能不能看出租户和行为分支,回滚是关开关、撤租户,还是要回退代码,这些都不是发布前最后补一句“加监控”就能解决的。
如果收口手段不清楚,需求就算代码写完了,也不能算真的接住。
四、再往下不是“我来验证”,而是先把验证拆对、责任拆对
我后来不太能接受一种默认想象:需求接到谁手里,谁就要替全流程背书。
这在单体或者单服务里还勉强说得过去。跨微服务需求不是这样。系统知识本来就分散在不同 owner 手里,很多边界不是把代码过一遍就能知道的。你要是继续按单点开发任务去分责任,最后一定会变成:
- 知识分散在各服务 owner 手里
- 风险识别散在各条路径里
- 责任却集中到需求实现者身上
这个结构本身就不对。
拆开来看,我现在会先把验证拆成机制层和业务层,因为这两层如果混在一起,最后就会变成一句特别空的话:反正你把这次需求验完。
机制层要确认的,通常是这些事情:
- 规则有没有命中
- 上下文有没有透传完整
- 开关和配置有没有按预期生效
- 日志、指标、告警能不能把新旧路径区分开
- 出问题时能不能快速关停和回退
业务层要确认的,通常是另一批问题:
- 哪些业务结果真的会变
- 哪些租户和路径最容易先出问题
- 哪些历史兼容逻辑最危险
- 哪些边界场景必须人工确认
我现在更倾向这样对上责任:
- 能力实现者负责机制层成立:规则命中、上下文表达、链路透传、灰度开关、回滚和观测
- 各服务 owner 负责业务层影响面:哪些业务路径会变,哪些历史兼容最危险,哪些边界最该先验
- 大家一起确认发布策略:先灰哪些租户,先看哪几条路径,什么现象出现就立刻停
这样做的目的,是让责任跟着知识走。
五、完成它,靠的不是一次“大验证”,而是分轮次收口
这类需求做到后面,我对“验证”这个词本身也有点改观了。
它不是一张越来越长的 case 清单,更像一轮一轮把风险压缩下去。
如果一上来就想证明“全场景都安全”,最后通常只会把自己拖进一个根本闭不住的任务。
因为这时候 case 当然还要跑,但问题已经超出了“再补几条能不能补齐”的范围。麻烦在于,你要看的已经从一个点变成了一串会跟着变的东西:服务落点会变,共享状态的读写关系会变,锁和幂等的边界会变,异步调用带不带灰度上下文也会变。你只能先压住最容易出事、最值得优先确认的那部分,再把测不完的尾部交给灰度和观测去接。
所以我现在更接受一种更老实的目标:放弃证明这次改动整体没问题,转而先把高风险部分压到可控,再把剩下那部分不确定性接住。目标一变,后面的验证动作才不会越做越假。
还有一个很关键的变化,是我已经放弃“所有组合都带到”这个念头,改为先明确主验证集。也就是先把最该优先压的那批路径挑出来,不再把每次验证都做成一轮从零开始的地毯式清点。
主验证集通常至少包括这些东西:
- 主业务链路
- 写操作和状态变化路径
- 带异步、重试、补偿的路径
- 历史上出过事故的组合
- 高价值租户、高风险配置、历史兼容最重的场景
- 一旦出问题就很难快速回滚的路径
我现在更常用的是四轮收口,大致是这样一个逐轮压缩风险的过程:
flowchart TD
Start["跨微服务需求接入"] --> R1["第一轮:收机制"]
R1 --> G1{"规则命中 · 上下文完整\n观测可区分 · 回退可控"}
G1 -->|"不成立"| R1
G1 -->|"成立"| R2["第二轮:收服务影响面"]
R2 --> G2{"各 owner 确认\n行为变化 · 状态互斥 · 边界路径"}
G2 -->|"未确认"| R2
G2 -->|"已确认"| R3["第三轮:收高风险链路"]
R3 --> G3{"主验证集已压过\n主流程 · 写操作 · 异步补偿"}
G3 -->|"未压住"| R3
G3 -->|"已压住"| R4["第四轮:灰度收口"]
R4 --> R4a["小范围灰度 → 盯日志指标 → 异常即停"]
R4a --> Done["需求真正做完"]
1. 先收机制
先确认最基础的东西能不能成立:
- 规则能不能稳定命中
- 上下文能不能带完整
- 新旧路径能不能被日志和监控区分
- 出问题时能不能快速关停或回退
这一轮不解决业务全局问题,只解决“这套能力本身是不是能控”。
2. 再收服务影响面
每个受影响服务都要回答自己的问题:
- 哪些行为真的变了
- 哪些状态判断、锁、缓存、幂等会被波及
- 哪些边界路径最容易出问题
这一步最好让 owner 明确给判断,不然最后总会有人以为“这个别人应该看过了”。
3. 再收主验证集里的高风险链路
目标是盯住最容易出事也最难救的部分:
- 主业务流程
- 写操作和状态变更路径
- 带异步、重试、补偿的路径
- 特殊租户、复杂配置、历史兼容最重的场景
我现在对“完成”的理解:关键在主验证集里的这些高风险路径有没有被切实压过,case 数量多少反而次要。
4. 最后用灰度收剩下那部分不确定性
做到这一步,通常还是会有没法彻底验证完的尾部。
这很正常。跨微服务需求很多时候就是做不到“上线前全讲透”。
所以最后的灰度,就是现实层面的收口:
- 先灰小范围
- 盯住关键日志和指标
- 一旦出现预先定义的异常信号,立刻停
有用的是灰度单位、观测点、停机条件、回滚动作都提前说清楚,光嘴上说“再观察一下”没意义。对这类需求来说,灰度、观测、回滚本身就是验证方案的一部分,不能当附属品来补。
六、现在我对“做完了”的定义也变了
以前我会更容易把“代码提了、case 跑了、灰度也开了”当成完成。
现在我更看下面这些东西有没有落住:
- 这次改动动了哪些系统前提,团队是不是已经看清
- 影响面是不是按 owner 拆开了,而不是糊在一个人身上
- 高风险路径是不是已经被单独验证过
- 灰度、观测、回滚是不是已经准备好
- 还有哪些没覆盖,大家是不是心里有数
这些东西都没落住,需求即使上线了,也只是“推过去了”,不算真正做完。
灰度只是这次把问题逼出来的入口。以后我会再单独写灰度该怎么做,但这次更重要的提醒在于接法本身。
到最后,这类需求能不能算结束,取决于你有没有把会变的对象、最危险的路径、还得靠线上灰度继续观察的那部分都摊开了。能做到这一步,我才觉得这次确实接住了。