TJXT 这个代号,是我们团队内部对“推荐系统”的拼音简写,喊顺了就一直没改。Day6,就是这套项目连续推进到的第六天。前五天我们把数据管道、召回、精排、上线评估这条链路从零搭了起来,到了第六天反而不急着加新功能,而是把线上服务整体拉出来做了一次“全面体检”:稳定性、效果波动、指标口径,一个都没放过。这篇手记就记录第六天到底做了什么,适合正在搭推荐系统、或者模型刚上线还没形成完整运维体系的朋友看,读完你至少能带走一套稳定性排查的思路,以及一套能落地的评估看板骨架。
1. 第六天该干什么:不是加功能,是给系统拆雷
1.1 前五天做到了什么程度
先说进度,不然单看 Day6 容易对不上上下文。
这个推荐系统不是从大厂成熟框架里抄的,是我们从零搭的一套轻量级方案。Day1 主要确认业务目标和埋点口径:用户要什么、推荐位权重怎么分配、曝光怎么定义。Day2 把数据管道跑通,从 App 端行为日志到离线数仓,再到训练样本生成,这一步最枯燥但最要命。Day3 做了多路召回,包括热门兜底、协同过滤 i2i、以及一个简单双塔向量召回。Day4 训练并上线了精排模型,用的 DeepFM 变体,特征服务挂 Redis。Day5 接上 AB 实验平台,模型小流量灰度。
到第五天晚上,线上已经能稳定返回结果了,但只是“能跑”,谈不上“稳”。第六天的任务很明确:把线上可能爆的雷提前拆掉。
1.2 为什么偏偏第六天做稳定性
很多团队会犯一个毛病:功能能跑就赶紧堆下一个需求,稳定性往后拖。结果往往是线上出了事故,查起来才发现一堆问题叠在一起,根本分不清是谁的锅。
我见过太多推荐系统挂掉的情况:特征服务抖动导致整体超时、缓存热 key 打到 Redis 卡死、模型服务线程池队列积压把 CPU 打满。这些问题不是模型训练出来的,是工程上欠的债。第六天停下来做体检,是因为前五天的功能刚好形成一个闭环,单链路的问题还没开始互相放大,现在排查成本最低。
1.3 Day6 的三个核心任务
- 稳定性检查:看接口耗时、CPU、内存、GC 是否有毛刺,是否存在隐患。
- 效果链路验证:实验组和对照组的核心指标趋势是否正常,波动到底是有意还是偶然。
- 指标看板统一:把在线业务指标和离线评估指标的口径对齐,避免各说各话。
这三个任务看着不性感,但每一个都是实打实的坑。下面我按当天的实际情况展开,重点讲排查思路和最后落地的方案。
2. 推荐系统链路拆解:从数据库到用户屏幕到底发生了什么
2.1 数据管道:样本质量和实时性是地基
推荐系统第一步不是模型,是数据。我见过不少项目把精力全放在模型结构上,结果样本穿越、特征落后,离线指标涨一堆,线上纹丝不动,甚至回退。
数据管道分实时和离线两条线。实时线处理曝光、点击、时长这类行为事件,经过消息队列进流式计算引擎,算用户短期兴趣特征。离线线处理历史全量数据,生成训练样本和长期特征。第六天我们重点检查了样本拼接逻辑:曝光日志必须和点击标签按同样的 request_id 对齐,负样本要做合理下采样,正负样本比例控制好。
有个很容易错的地方:把当前时刻的用户特征拼到历史样本里。这属于特征穿越,模型在训练时拿了未来信息,线下评估虚高,一上线就露馅。Day6 我专门写了一个校验任务,随机抽几千条训练样本,把特征时间戳和 label 时间戳拉出来比对,保证特征生成时间严格早于曝光时间。
2.2 召回层:多路并行,融合有讲究
召回层解决的是“从十万候选里取出一千个”的问题。我们用了三路:
- 热门召回:按热度排序取 TopN。简单粗暴但必须有,这是冷启动用户和新物品的保底。
- 协同过滤 i2i:基于物品共现关系,找当前已点击物品的相似物品。
- 向量召回:双塔模型把用户和物品映射到同一向量空间,用近似最近邻检索取 TopN。
多路召回不是简单合并就完事。Day6 我们分析了各路召回的曝光占比:热门召回占比超过 40%,说明系统在偷懒,新物品和长尾物品几乎没有出头机会。这一步后面要调融合权重,但当天我只记录问题,不过度改动。
另外必须保留 1%-2% 的随机探索流量。没有探索,新物品永远进不了候选池,系统会越来越固化。
2.3 精排模型:选型要克制,校准不能省
精排层把召回的一千个候选精确打分排序。模型选的 DeepFM 变体,原因很简单:效果好、工程成熟、好解释。复杂模型不是不能用,但推荐系统的收益往往不在模型结构多先进,而在特征和样本的细节。
特征分三类:用户特征(历史点击偏好、活跃度)、物品特征(类目、价格、新品标记)、上下文特征(当前时段、请求来源、屏幕位置)。第六天我们检查了特征覆盖率,发现部分实时特征缺失率接近 15%,原因后面在实操部分细说。
精排上线前还有一个容易漏的环节:分数校准。模型输出的概率是未校准的,不校准的话,线上排序和业务规则对接会出现偏差。我们用保序回归校准了一下,效果不惊艳但稳定。
2.4 服务化与重排:用户感知的最后一道关
模型打分之后还要过重排层:去重、多样性控制、业务规则插入。比如同类目物品不能连续出现,新品需要扶持位,商业化内容要有固定占比。我们用 MMR(最大边际相关)做多样性重排,在相关性和新颖度之间取平衡。
服务端整体是一个 RPC 接口,流程是:接入层拿用户请求,召回层并行取候选,特征服务组装特征,精排模型批量打分,重排层处理后返回。任何一环变慢都会拖垮整体。第六天排查的 CPU 毛刺,问题就藏在召回层的逻辑里。
3. Day6 实操现场:一次真实的线上稳定性排查
3.1 现象:晚上八点 CPU 毛刺,接口超时上升
监控图是我们自己搭的,指标不多但够用。第六天下午看曲线时发现,晚上 20:10 左右有一个明显的 CPU 毛刺,模型服务单实例 CPU 从 25% 瞬间飙到 80%,随后回落。同时接口 p99 延迟从 30ms 跳到 180ms,还出现少量 504 和 408。
第一反应是流量突增。但查了入口流量曲线,20:10 只比平时涨了 10%,不至于引起这么大的波动。那就说明系统内部有问题,不是外部压力造成的。
3.2 排查过程:从线程池到 Redis 再到 GC
排查顺序是从服务端往上游走。
先看模型推理服务。我们用的是容器化部署,单个推理实例配了 8 核 CPU。第一反应看 GC 日志,果然发现老年代回收频率异常,单次 Full GC 停顿超过 500ms。GC 一停,请求排队,线程池队列积压,CPU 被烧上去。
再看线程池配置。我当时写的是 core=8,max=8,无界队列。无界队列是个大坑:一旦上游变慢,请求全部堆在内存里,内存持续上涨,GC 压力更大,最后雪崩式超时。
继续往下查特征服务。精排打分需要 80 多个特征,大部分缓存在 Redis。代码里用了批量获取,但实际没有走 pipeline,而是 for 循环一条一条 MGET。平时流量低没问题,一旦热点用户集中请求,Redis 的响应时间直接翻倍,特征服务慢,模型服务就只能干等。
还有个细节:部分用户的特征 key 特别热,集中在几个头部用户上,低频 key 反而很多。这导致 Redis 单分片热点明显,某个节点 CPU 冲到 90%。
3.3 优化落地:有界队列、pipeline、降级开关
排查结束后当天做了四处改动,改动都不大,但效果立竿见影。
第一处:线程池改成有界队列,拒绝策略用 CallerRunsPolicy。这样当系统过载时,请求不会无限堆积,而是由调用方线程执行,本质上是把压力反向传导,让上游自己感知超时。核心线程数保留 8,最大 16,队列容量设定 400。参数不能照抄,要根据压测结果调,但方向的正确性可以确认。
第二处:Redis 获取特征改成 pipeline 批量提交,一次网络往返拿回所有 key。同时把最热的那几个用户特征加了一层进程内本地缓存,过期时间设 2 秒。这个 2 秒不是随便拍的,是从“特征允许的延迟时间”反推的,超过 2 秒的延迟对排序影响可以忽略,但能挡住 90% 的热点穿透。
第三处:增加特征降级开关。以前特征缺失就直接填默认值,这不对。填均值或默认值会让模型输出一个“看起来正常但实际是猜的”分数,很多线上事故都是这么来的。改成:特征服务超时或缺失率达到阈值时,跳过精排模型,直接退化为粗排分数,或者用多路召回阶段的得分。宁可给一个不精准但不会崩的分数,也不要给一个模型瞎凑出来的数字。
第四处:请求超时控制。整个推荐接口设置 150ms 软超时、250ms 硬超时,上游任意子链路超过 50ms 就触发降级,不让慢请求拖垮整体。
3.4 验证结果:压测和线上灰度
改动后先做一轮压测,用 2 倍峰值流量打,p99 稳定在 50ms 以内,CPU 平均 30%,没有再出现毛刺。
线上放量前,我特意看了当天的效果指标有没有因降级而波动。这里有个容易被忽略的点:降级策略本身要可观测。我们给降级次数单独打了个 tag 记录下来,一旦线上降级次数异常增多,说明系统又在报警边缘了。这个叫“降级可观测”,Day6 之后我把它写进了上线检查清单。
4. 效果评估:怎么知道推荐系统是变好了还是变差了
4.1 指标口径要统一,不然全是糊涂账
推荐系统上线后,评估指标五花八门,业务要 GMV,算法要看 CTR,老板要时长。第六天我们做的事是把指标口径统一固化到看板里。
核心指标定四个:
- CTR:曝光到点击的转化率,反映推荐相关性。
- UCTR:有效点击率,过滤掉误点,更接近真实兴趣。
- 人均浏览时长:反映推荐内容的消费深度。
- 多样性指标:曝光集合里类目分布的熵,防止推荐结果越推越窄。
口径最重要的一件事,是明确什么是“曝光”。如果用户进入首页但不翻到推荐位,后端已经拉取了接口但前端没展示,这算不算曝光?我们的统一口径是“进入首屏且该推荐位可见”才算曝光,然后在这个基础上统一点击分母。这个不上看板时说不清楚,一上就看出来各团队之前对不上账。
4.2 AB 实验分层:一次能测多少个变量
第六天我们把 AB 实验平台的分层策略最终确认下来:按流量分层,层内互斥,层间正交。
意思很简单:所有实验共用同一批流量池,但不同实验分在不同层,同一个用户只能在一个层的同一实验里命中一个分组,但不同层之间的实验互不影响。比如召回实验在 A 层,精排模型实验在 B 层,一个用户被 A 层的实验命中,不影响他在 B 层被精排实验命中。这样一次线上能同时跑多个实验,互不污染。
实验设计还有个小坑:样本量不够就下结论。CTR 这类比例类指标,样本量估算公式是:
n = (z_alpha/2 + z_beta) 的平方,乘以 p1(1-p1) + p2(1-p2),除以 (p1-p2) 的平方。
真实场景我举一个例子:当前 CTR 5%,想检测 5% 的相对提升(也就是从 5% 涨到 5.25%),设显著性水平 0.05、统计功效 0.8,算下来每组大概需要几十万次曝光。如果只跑了一天只有十万曝光,那就是随机波动,不要轻易判定“没效果”。
4.3 指标波动归因:检查清单比拍脑袋可靠
线上指标一波动,第一反应往往是“新功能生效了”或者“模型回退了”。但真实原因可能很简单:节假日流量结构变了、某条埋点被前端版本升级顶掉了、甚至机房某个区域网络抖动。
第六天我把归因检查做成固定清单:
- 流量结构是否变化:新用户占比、渠道来源。
- 埋点是否稳定:曝光和点击日志的数量曲线。
- 特征缺失率是否变化:当日实时特征缺失率。
- 模型版本是否唯一:有没有灰度环境把旧模型和新模型混跑。
- 外部业务动作:营销活动是否拉高了点击但拉低了时长。
按清单过一遍,至少能排除 80% 的“假波动”。剩下的再往深层挖。
5. 踩坑实录:推荐系统上线后的常见问题速查
5.1 我遇到过的典型问题和排查方法
第六天不光是看监控,也把前五天积累的坑整理了一遍,做成一张速查表。以后不管是自己还是新同事看到类似现象,先查表。
- 问题:接口超时飙升。可能原因:线程池队列积压、上游链路变慢、GC 停顿。排查方法:查看线程池活跃度、上游链路分阶段耗时、GC 日志。解决办法:有界队列、超时控制、降级开关。
- 问题:推荐结果高度同质化。可能原因:热门召回占比过高、重排策略失效、探索比例太低。排查方法:统计各召回路曝光占比,看 MMR 参数是否生效。解决办法:压低热门权重、提高随机探索比例、调大多样性惩罚。
- 问题:离线指标涨但线上不涨。可能原因:训练样本穿越、特征一致性差。排查方法:对比训练样本时间戳和线上实时特征,做特征 diff。解决办法:统一特征生成逻辑,训练和线上共用特征服务。
- 问题:新用户冷启动效果差。可能原因:无行为特征可依赖。排查方法:看推荐结果是否千篇一律全是热门。解决办法:用设备信息、地理位置、当前时间做上下文特征,冷启动用户先给热门探索。
- 问题:模型分数整体偏高。可能原因:缺少校准层。排查方法:对比模型预测均值和实际点击率。解决办法:接入保序回归或温度缩放校准。
5.2 第六天之后最想强调的三条经验
第一,一致性压倒一切。训练样本里特征怎么生成,线上就得一模一样地生成。任何不一致都会导致离线评估失真,最后浪费的是整个团队的时间。
第二,可观测性是排障的第一生产力。没有监控就没有解 bug 的入口。CPU、GC、线程池活跃度、特征缺失率、降级次数,这五个指标建议第一时间接到告警上,比模型指标优先。
第三,越简单的方案越抗住流量。无界队列、无限重试、不加缓存的直连,这些“图省事”的写法,最后都会在流量峰值时变成定时炸弹。宁可多写几行降级逻辑,也不要赌“不会出问题”。
还有一点必须提醒:线程池参数、缓存过期时间、降级阈值,全是业务相关的参数,不要复制照抄。我上面的数值是基于我们当时两倍峰值流量压测出来的,你的系统要按你自己的请求量重新压。
到第六天晚上,我把整套推荐链路画在一张单页架构图上,标出每一个服务、每一条依赖、每一个降级开关。图片不重要,重要的是画图的过程逼着我把链路里每一个环节重新过一遍,确认没有盲区。推荐系统做到这个阶段,真正拉开差距的已经不是模型有多新,而是线上能不能稳定跑、指标能不能说清楚、出了问题能不能快速定位。第七天我计划回头啃冷启动这块硬骨头,后面有实质进展了再单独记录,这里就先收笔。