接口服务里的 A/B Test:从灰度开关到可信实验
古董级程序员,大厂出来后一直在创业公司,现在仍活跃在一线做 AI 相关的开发。更完整的更新写在微信公众号「字与码」:工作经历、对新技术的想法,以及这些年走弯路的记录,会不定期发在那里。若觉得博客对你有用,欢迎顺手关注。
以前我对 A/B test 的第一反应也是前端:按钮颜色、落地页标题、价格页卡片。后来做接口服务多了,才发现更难的实验反而在后端。
搜索排序换一套策略,工具选择多加一个供应商,参数修复逻辑调得更激进,某个慢接口要不要提前降级,甚至一次缓存命中策略调整,都可能让成功率、延迟、成本和用户体验同时变化。上线前在测试环境跑通,只能证明它“能跑”。真正的问题是:它在真实流量里是不是更好?
如果只是开一个灰度比例,这件事还不算 A/B test。灰度能降低爆炸半径,但不能回答“新方案是否值得全量”。要回答这个问题,必须有稳定分组、真实曝光、结果归因、指标窗口和护栏。否则最后只会得到一堆看似漂亮、其实不能用的数据。
这篇写的是我现在更愿意采用的一套接口服务实验做法。不是某个项目复盘,也不贴工作里的代码,只讲可复用的架构判断。
灰度不是实验
Feature flag、灰度、A/B test 经常被放在一个后台里,但它们不是一回事。
Feature flag 管的是发布开关。代码已经部署,功能先不对所有人打开,这是它最基本的价值。
灰度管的是风险。先给 1% 流量,再给 5%、20%、50%,一边放量一边观察系统是否扛得住。
A/B test 管的是判断。它要比较新旧方案的效果,并且这个比较要尽量可信。可信这两个字很重,意味着实验单位不能乱、分组不能飘、曝光不能漏、结果不能对不上、指标不能临时改。
Unleash 的实验方案把 feature flag、变体和 impression data 串在一起,这个思路很工程化:开关负责交付,变体负责差异,曝光事件负责告诉分析系统“谁看到了什么”。Statsig 对 raw events 的说明也很清楚:曝光事件用来说明分组,自定义事件用来说明后续行为。两者缺一块,实验结果就很难读。
接口服务还有一个前端实验不太常见的问题:一次用户行为往往跨多个请求。用户先检索,再执行;先创建任务,再轮询结果;先拿报价,再确认购买。真正被实验影响的可能是第一个请求,但结果要到后面的请求才出现。如果中间没有把实验上下文带过去,后面的成功率和成本就归不到正确的变体上。
我会把实验层放在业务逻辑前面
一个后端实验系统,不应该散落在业务代码里。比较稳的形态,是在请求进入业务逻辑之前先生成一个“实验上下文”。
请求带着用户、会话、API key、站点、环境、地域等信息进入实验层。实验层只做四件事:找出正在运行的实验,判断这个请求是否符合目标人群,按稳定规则分桶,返回本次请求应该使用的配置。业务逻辑只消费配置,不直接关心分桶细节。
这个拆法的关键,是让实验层保持通用。它不需要知道业务是在做搜索、支付、推荐还是文档处理。它只认识这些概念:
| 概念 | 作用 |
|---|---|
| 实验 | 一次可开关、可暂停、可结束的比较 |
| 作用域 | 这次实验影响哪个服务或接口族 |
| 目标人群 | 哪些用户、环境、地域、标签可以进入 |
| 流量比例 | 总共有多少符合条件的主体进入实验 |
| 变体 | control、treatment 或更多方案 |
| 配置快照 | 本次请求实际使用的参数 |
业务层拿到的不是一句“你是 treatment”,而是一份已经确定下来的配置。比如“使用新排序策略”“候选数量上限提高一点”“某个供应商权重降低”“超时时间缩短”。配置可以简单,也可以复杂,但业务代码最好只认固定的配置入口,而不是到处判断某个实验名。
这样做还有一个好处:实验结束后容易清理。新方案赢了,就把配置固化为默认行为,删掉实验分支;新方案输了,就把相关入口移除。最怕的是实验名散在业务代码各处,两个月后没人敢动。
分桶的第一原则是稳定
后端实验里最不能偷懒的是分桶。
有些系统会在请求到来时随机一下,按比例决定走新方案还是旧方案。这样做看起来公平,实际很糟糕。同一个用户今天走 A,明天走 B,他的行为已经被两种体验混在一起。更麻烦的是,后续请求可能跟前面的请求不在同一组,结果归因会乱。
比较可靠的做法,是先定义实验单位,再围绕这个单位稳定分桶。
登录产品通常用用户 ID。开放接口更适合用 API key 的不可逆摘要。匿名短会话可以用 session。这里没有永远正确的答案,只有“这个实验到底想比较什么”的答案。想比较用户体验,就不要用请求作为单位;想比较调用级策略,也要确认同一条链路里的请求不会前后矛盾。
稳定分桶还有一个实际好处:流量从 5% 放到 20% 时,原来那 5% 用户不应该突然换组。新进来的 15% 再继续按变体权重分配即可。这个细节很小,但能少很多诡异波动。
如果分桶主体稳定、分桶算法也稳定,很多实验不必把每次 assignment 都写进数据库,按规则实时算就够了。但有几类场景最好持久化:需要审计、需要人工指定、多个系统用不同语言共同参与、或者实验单位本身可能变化。不要为了少一张表,把后面的解释成本留给自己。
目标人群要写成配置
后端实验常常不是“一上来所有用户都能进”。更常见的是先给内部账号,再给测试环境,再给小客户或低风险用户,最后才扩到核心流量。
所以目标人群必须是配置,不应该写死在代码里。至少要能描述这几类条件:
| 维度 | 典型用途 |
|---|---|
| 环境 | 测试、预发、生产隔离 |
| 用户或 API key | 白名单、黑名单、重点客户排除 |
| 用户标签 | 内测、付费层级、企业客户、低风险样本 |
| 地域或站点 | 国内外站点、不同合规环境 |
| 时间窗口 | 自动开始、自动结束,避免长期悬空 |
| 流量比例 | 从小比例逐步扩大 |
环境隔离尤其重要。实验配置如果不带环境,很容易出现测试同学在后台开了一个 100% treatment,线上也跟着生效的事故。实验系统宁愿默认保守,也不要默认跨环境。
配置快照比变体名更重要
很多实验后台喜欢只记录 control 和 treatment。对接口服务来说,这远远不够。
因为后端变体往往不是一段 UI,而是一组参数:超时、重试、候选上限、排序策略、供应商权重、缓存策略、fallback 顺序。只记录变体名,事后看日志时经常会遇到一个问题:当时 treatment 里面到底是什么配置?
所以每次请求命中实验时,除了实验名和变体名,还应该记录当时的配置快照。配置后来可以改,但历史请求不能跟着变。没有快照,复盘会非常难受。
配置也要尽量通过稳定的业务入口消费。例如业务层认“排序配置”“超时配置”“降级配置”,而不是认某个实验名。这样实验是实验,业务能力是业务能力,两者不会绑死。
跨请求继承是接口实验的命门
前面说过,接口服务里一个完整行为经常跨多个请求。这里最容易出错。
假设第一个请求决定了候选顺序,第二个请求才真正调用候选里的某个能力。第二个请求的成功、失败、延迟、价格,当然应该归因到第一个请求的实验变体。否则你只能看到“新策略让用户点了不同东西”,但看不到“不同东西后面到底好不好用”。
我现在会要求:凡是第一个请求发生了实验干预,就要把实验上下文写进这条链路的历史里,并且短时间内放一份快速缓存。后续请求拿链路 ID 查回原来的实验上下文,查不到再降级处理。
这个快速缓存不是为了省数据库查询,而是为了避免异步写历史带来的竞态。用户的第二个请求可能紧跟第一个请求发生,历史记录还没落库。如果这时查不到实验上下文,后续数据就会被错误归因。
常见的链路 ID 可以是搜索 ID、任务 ID、报价 ID、会话 ID。叫什么不重要,重要的是这条链路能串起来。
曝光应该发生在干预点
什么时候算“曝光”?这个问题比看上去难。
用户只是打开页面,但没有触发实验逻辑,算不算?后端只是读取了一个开关,但最终业务分支没走到,算不算?如果都算,实验样本会被大量没有真正经历变化的请求稀释。
我更倾向于把曝光放在干预点。排序实验就在排序策略真正生效时记录;超时实验就在该超时配置真正用于调用时记录;供应商选择实验就在候选供应商真正进入决策时记录。
曝光事件里至少要能回答几件事:谁被分到了哪组,基于什么实验单位,在哪个环境,影响了哪条请求链路,发生在什么时候。这里的“谁”不应该是明文邮箱、明文 token 或原始 API key,而应该是不可逆的稳定标识。实验分析需要可 join,不需要泄露身份。
还有一个常见坑:只记录 treatment,不记录 control。这样后面根本没法公平比较。control 也是实验的一部分,只要它在同一个实验设计里,就应该有曝光记录。
指标先分层,再谈显著性
一次实验开始前,最应该写清楚的是指标,而不是流量比例。
我一般分四层看。
主指标用来决定是否上线。它应该尽量贴近这次改动的目标。排序实验看任务完成率或后续成功率,执行策略实验看有效成功率,成本优化实验看单位成本下的有效结果。主指标不要太多,多了最后只会挑对自己有利的解释。
解释指标用来理解发生了什么。比如候选数量、重试次数、fallback 比例、某类供应商占比、缓存命中率。它们不直接决定上线,但能帮你知道主指标为什么动。
护栏指标是底线。延迟、错误率、超时、成本、投诉、限流、下游失败,都属于这一层。主指标涨了,但 P95 延迟或错误率明显恶化,这种实验不能直接推全。
数据质量指标决定这次实验能不能信。最典型的是 SRM,也就是实际分组比例和设计比例显著不一致。Kohavi 相关的在线实验资料反复强调这一点:SRM 通常说明随机化、埋点或过滤逻辑出了问题。出现这种问题时,先别急着读业务结果。
Statsig 的实验建议里还提到 MDE,也就是你希望能检测到的最小变化。这个概念对后端很有用。有些技术改动对顶层收入影响很小,但对更近的过程指标很敏感。与其用一个慢得要命的顶层指标拖住实验,不如提前选好更贴近干预点的主指标,再用长期指标做补充验证。
监控可以实时,决策不要实时
实验一开,最容易忍不住频繁看结果。上午 treatment 涨了,想推全;下午掉了,又想关掉。这样做很危险。反复偷看主指标,并根据短期波动做决策,会把随机噪声当成结论。
但这不代表实验期间不能看数据。健康监控应该实时,最终读数应该克制。
实时监控看的是系统有没有出问题:错误率、P95/P99 延迟、成本、曝光量、SRM、日志缺失。严重恶化时可以自动暂停或降流量。
最终读数看的是实验是否达成目标:按实验前约定的窗口读,不因为中途曲线好看就提前宣布胜利。这个纪律很笨,但很有用。
自动关停也要谨慎。适合自动触发的是系统健康类指标,不适合用短期业务涨跌触发。否则实验平台会变成一个对噪声过敏的发布器。
实验系统坏了,主流程不能坏
实验层在请求路径上,所以它必须低调。配置加载失败、曝光写入失败、指标管道延迟,都不应该让正常业务请求失败。
比较稳的策略是:有旧配置就继续用旧配置;没有配置就不进入实验;曝光写失败只记录告警;跨请求上下文查不到时允许降级,但要留下可排查的日志。实验系统是为了帮助发布,不是为了成为新的单点风险。
配置缓存也要适度。刷新太慢,后台调整半天不生效;刷新太快,配置服务抖动会传到每个请求。多数接口服务用几十秒级缓存已经够用,真正高流量的场景再考虑把配置预编译成内存结构。
A/A test 是第一道验收
第一次搭实验层时,最好先跑 A/A test。也就是两组走完全一样的逻辑,只验证实验系统本身。
A/A test 不是形式主义。它能查出很多平时看不到的问题:同一用户是否稳定分组,曝光是否漏记,结果事件能否 join,分组比例是否正常,分环境、分地域、分用户层级以后是否出现偏差。
如果 A/A test 都跑不平,A/B test 的结论就不要信。很多团队不是不会做实验,而是太早相信了自己的尺子。
落地时不要一口吃太大
如果从零做,我不会一开始就做一个完整实验平台。顺序可以更朴素。
先把实验定义和稳定分桶做出来,只接一个低风险接口。然后把曝光写进请求历史,让它能和后续结果串起来。再做窗口级指标快照,先看调用量、成功率、延迟、错误率、成本这些硬指标。等这些跑顺,再补管理后台、审计、自动护栏和更细的统计读数。
最怕的是先做漂亮后台,滑块、图表、权限都齐了,最后发现曝光和结果对不上。实验平台的核心不是页面,而是数据链路。
几个我会反复检查的坑
请求数平衡不等于用户数平衡。随机化单位如果是用户,就应该按用户检查分组比例。重度用户天然会产生更多请求。
control 也要记录曝光。只记录新功能那一组,会让对照组在数据里消失。
实验名不要散落在业务代码里。业务代码应该认稳定的配置入口,实验名属于运营层。
过期实验要清理。赢了就固化,输了就删掉。长期挂着的实验分支,最后都会变成没人敢碰的历史包袱。
不要只看平均值。接口服务的用户体感经常由长尾延迟决定,P95 和 P99 比平均值更诚实。
不要中途改主指标。可以加排查指标,但用于决策的主指标和窗口应该在实验前定好。
模型类接口更需要这套纪律
现在很多接口后面接的是模型或复杂策略。离线评估能过滤掉明显不靠谱的方案,但很难覆盖真实用户输入、真实调用链路和真实成本结构。
这类实验尤其要看成本和长尾。一个方案质量略好,但成本大幅上升,未必值得上线;平均延迟好看,但 P99 不可接受,也不能推全。至于质量评估,如果用了人工或模型打分,也要承认它本身有噪声,不要把一次评审当成真理。
说到底,A/B test 不是为了让每个发布都显得“数据驱动”。它更像一套发布纪律:小流量开始,同一主体稳定体验,真实发生干预才记录曝光,曝光能和结果串起来,读数前先看数据质量,结束后清理代码。
做到这些,新方案不一定会赢。但至少团队知道自己为什么上线,为什么回滚,也知道哪些数据值得信。
参考资料
- Unleash: Implement A/B testing using feature flags
- Statsig: Product experimentation best practices
- Statsig Docs: Raw Events
- Ron Kohavi, Roger Longbotham: Online Controlled Experiments and A/B Testing
- Microsoft Research: Diagnosing Sample Ratio Mismatch in A/B Testing
- Statistical Challenges in Online Controlled Experiments