这次写一篇关于机器学习平台建设的东西,算是对过去几年踩坑经历的一个系统性复盘。项目背景是公司大数据架构演进到一定阶段后,算法团队和基础架构团队不得不坐下来,认真解决一个被拖延很久的顽疾——特征不一致和模型上线链路太脆。这篇文章不会端着一个庞大平台的架子,讲什么天上一脚地上一脚的架构蓝图,而是把特征存储和模型部署这两个最容易出问题的环节拆开,结合真实案例说清楚。
1. 线上事故复盘:训练特征与推理特征错位的那两周
先讲一笔真实的烂账。
去年年中,我们一个核心推荐位模型的离线评估指标涨了接近两个点,团队还挺高兴,结果灰度上线之后,线上转化率不仅没涨,反而掉了0.6%。这种“离线涨、线上跌”的现象在业内不新鲜,但以往幅度小,大家睁一只眼闭一只眼。这次幅度大,压不住了,全链路排查下来,根因一点都不神秘,就是训练时用的特征和线上推理时用的特征,根本不是一个口径。
具体来说,模型训练的特征管道是从离线Hive数仓取数的,按天分区,凌晨跑批,算的是T-1的全量用户行为聚合特征。同一个特征的线上版本,为了控制时延,是从实时Redis里读的,字段名叫user_click_30d_cnt,但离线那张表里对应的字段实际含义是“近30天点击去重数”,线上实时算的是“近30天点击次数不去重”。字段名一样,语义差了一点,差异在大多数用户身上不明显,但在高频用户的样本上会被明显放大。还有一批特征是走Flink实时算的,但实时任务偶尔会延迟,特征管道没有设置有效时间窗口,把延迟期间的不完整中间状态也写进了Redis,训练数据里自然没有这部分噪声。
这期间最痛苦的还不是定位问题,而是定位手段的缺失。特征没有统一注册、统一版本管理,团队成员靠一份Google Sheet记录特征口径。真要追溯某个特征在某一天被谁改过、为什么改,根本查不到。
这次事故之后,我们把两件事提到了最高优先级:第一是建设一个统一的特征存储层,所有离线和在线特征从定义到取值共用一套元数据;第二是把模型部署从“能跑就行”变成“可追溯、可回滚”的标准流程。下面这些内容,算是我对这两个方向在实践层面的完整梳理。
2. 特征存储的设计决策:数据模型、新鲜度与选型权衡
2.1 表格逻辑模型:实体、事件时间与特征组
特征存储看起来是个存储问题,本质上是个建模问题。
我们最后定下来的逻辑模型,核心是三个概念:实体(entity)、事件时间(event timestamp)、特征组(feature group)。
实体就是特征的主体,用户ID、物品ID、设备ID都算。特征存储里的每一行特征,必须能回答三个问题:属于谁、在什么时间点有效、是哪组特征。在具体表结构上,我们参考了Feature Store领域比较通用的设计,每张特征表强制带两列:entity_id和event_timestamp,剩下的才是业务特征列。
event_timestamp这列一开始很多人不理解,觉得“数据有更新时间不就行了?”但更新时间和事件时间是两回事。用户昨天下午3点完成了一笔订单,订单时间是事件时间;这条记录写进存储的时间是凌晨跑批的2点,那是更新时间。训练时我们要的是事件时间,因为只有它才能正确表达“模型在某一时刻能观测到什么”。
逻辑模型定了之后,物理存储开始分叉。离线侧所有历史特征都追加写入Iceberg表,按event_timestamp分区;在线侧只保留最近一段时间的高时效特征,写入Redis或内存缓存。
这个模型设计里最有价值的一点是:特征组的概念让权限和生命周期管理变得简单。风控特征组、推荐特征组、搜索特征组各自归属不同团队维护,过期删除策略按特征组配置,不会互相影响。
2.2 在线与离线的一致性:同一份定义,两条数据管道
特征存储最核心的价值,是强制在线和离线使用同一份特征定义。我们在实现上靠的是“定义驱动”。
每个特征在注册时,必须绑定一个特征定义文件(JSON Schema),文件里写清楚:特征名、所属特征组、实体类型、数据类型、聚合窗口、聚合函数、适用在线/离线/双通道。离线管道和在线管道都由这份Schema生成代码。离线走Spark,在线走Flink或直查Redis,两边生成的是同一份逻辑。
举个例子:
假设要注册一个特征user_coupon_usage_7d,定义为“用户近7天使用优惠券订单数”。离线侧生成的Spark SQL是:
SELECT user_id AS entity_id, order_time AS event_timestamp, COUNT(*) AS user_coupon_usage_7d FROM orders WHERE coupon_id IS NOT NULL GROUP BY user_id, order_time在线侧生成的Flink SQL是:
SELECT user_id AS entity_id, event_time AS event_timestamp, COUNT(*) AS user_coupon_usage_7d FROM orders WHERE coupon_id IS NOT NULL GROUP BY user_id, TUMBLE(event_time, INTERVAL '1' DAY)虽然计算引擎不同,但窗口口径、过滤条件、聚合函数都来自同一份Schema,两边语义天然对齐。
但这里有一个容易踩的坑:窗口滑动方式。离线批处理用固定日历天窗口没问题,在线Flink如果用滚动窗口,遇到系统时间跨天时,滑动窗口和固定窗口算出来的结果会不一致,直接在线上也能出现“为什么我的特征凌晨突然跳动”的疑惑。我们的处理方式是在Schema里显式声明窗口类型和时区,实时任务统一用和离线一样的日历对齐方式,禁止默认的24小时滚动窗口。
2.3 开源方案与自研的边界:Feast、Tecton和我们最后的选择
这个环节我们认真评估过Feast和Tecton,也聊过要不要完全自研。
Feast是开源里名头最大的,它的定位是特征存储中间层,支持离线源(BigQuery、Redshift、Hive)和在线存储(Redis、Firestore)同步。小团队想快速跑通一套特征管理流程,Feast很合适,社区文档也不算差。但Feast的问题在于,它本质上偏“调度和元数据管理”,对特征管道本身的计算逻辑介入很浅。你要是希望平台能自动做数据质量监控、自动感知上游数据延迟并阻塞训练任务,Feast原生能力是不够的,需要大量二次开发。
Tecton是商业化产品,在AWS上体验很完整,自动做训练服务一致性、实时/离线血缘追踪都做得很顺。但因为它绑定云生态比较深,国内私有化部署的场景会比较麻烦,成本也不低。
我们最后的选择是:自研核心,但借鉴Feast的元数据模型和API设计。因为我们的数仓是Iceberg + Spark + Flink这套组合,团队对Flink和Spark的控制力足够。自研的核心模块就是三个:特征注册中心、特征管道编排器、在线存储同步器。整体工作量大约两个后端加一个数据工程师全职投入三个月,这还是在复用已有调度平台的前提下。
如果你正在做选型,我的建议是:百人以内团队、特征数量几百个,用Feast足够;特征上千、实时特征比重大、又对质量有高要求的,基本只能自研或商业方案。中间路线往往会更痛苦。
3. 特征管道与数据质量:比模型调参更影响效果的环节
3.1 点查询与批量拼接的正确姿势
特征存储有两种读取模式:训练期的批量读取和推理期的点查询。很多设计在这两者之间来回摇摆,最后两边都没做好。
批量读取用于生成训练样本,典型操作是拿一张包含用户ID、物品ID和时间的样本表,去关联特征表取对应时间点的特征值。这里最核心的操作是asof join,也就是时间点连接,取的是“样本时间点之前最近一条特征”。
这个操作用SQL表达比较复杂,Flink和Spark的ASOF JOIN支持程度还不一致。我们最后是用Spark的SQL扩展自己实现了一个ASOF JOIN函数,核心逻辑是:
- 样本表和特征表都按实体ID分区。
- 每个分区内按事件时间排序。
- 对每个样本行,二分查找特征表中事件时间小于等于样本时间的最近一条记录。
这个实现比直接用ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)的通用写法要快一个数量级,因为后者会产生巨大的中间膨胀,尤其是特征表和样本表都是亿级规模时,普通的关联写法会直接把Spark Driver搞OOM。我们一开始就被这个问题卡了一整周,后来改成自研函数才算解决。
点查询的姿势也值得一提。线上推理时,模型服务拿到用户ID和物品ID,需要快速取到这批特征。很多团队为了省事直接批量查Redis,但一个请求要查几十个特征、每个特征又是一次RTT,特征一多延迟直接爆炸。我们的方案是:在线特征按实体分桶,同一实体的常用特征在Redis里存成一条Hash结构,点查询时用HMGET一次性取回。Redis的Hash在key数量很大时内存效率也不错,算是性价比很高的方案。
3.2 训练数据泄露:特征存储最容易忽略的隐形杀手
数据泄露这个问题,在没有统一特征存储时,几乎是无解的。
典型场景是这样的:某个用户维度的特征user_total_spend,凌晨跑批算出的是“截止昨天”的总消费。训练任务是下午跑的,从Hive表里取到的数据带有当天凌晨完成的最新状态。但线上推理时,这个特征的值可能还是前一天的。也就是说,训练时模型可以看到“当天”的信息,推理时却只能看到“前一天”的信息,模型的离线评估结果自然虚高。
按规范,在线特征存在一个时间延迟T,那么训练样本的事件时间就应该选择T-1之后的时间段。我们通过在特征注册中心为每个特征配置sla_latency字段,让离线拼接自动截断“未来特征”——凡是特征的事件时间晚于样本时间超过延迟窗口的,一律不参与拼接。
但这里又有个坑:时区。看起来是个小问题,但踩过的人才懂。特征管道的分区时间如果用本地时间,而样本时间用UTC,离线拼接时会出现“边界时间特征缺失”。最典型的就是每日的0点到8点之间训练任务取不到当天的特征,因为Hive表的分区以本地时间命名,但拼接逻辑按UTC转成了前一天。后来我们做了一个强制性规定:特征存储内部统一使用UTC时间存储,展示层再转本地时区,所有分区字段一律用dt字符串格式并且全部UTC对齐。这条规则执行之后,跨天特征缺失的问题基本绝迹。
3.3 特征新鲜度分级与延迟预算
不是所有特征都需要实时算出,把实时特征和离线特征搅在一个池子里管理,只会让系统变得复杂且脆弱。我们按新鲜度把特征分了三级:
| 级别 | 新鲜度要求 | 计算引擎 | 场景示例 |
|---|---|---|---|
| T0级 | 秒级/分钟级延迟 | Flink实时计算 | 用户当前会话行为、实时点击序列 |
| T1级 | 小时级延迟 | Spark微批/Flink | 用户近1小时浏览偏好、商品实时热度 |
| T2级 | 天级延迟 | Spark离线批处理 | 用户月消费统计、长期兴趣画像 |
这个分级直接映射到部署架构上:T0特征进入Redis热路径,T1特征既可以进Redis也可以进本地缓存,T2特征只需要离线存在Iceberg里,训练时关联即可,推理时如果需要,提前一天预载到在线存储。
延迟预算这个概念,是和业务方对齐的最关键表格。我们在每个特征注册时都记录了“数据从产生到可被人读到”的全链路延迟预算,比如实时特征链路延迟包含上游Kafka写入时间、Flink计算时间、Redis写入时间,总预算控制在30秒内。一旦某个环节超时,特征管道会直接报警并丢弃该批次数据,绝不写入不完整数据。
这个“宁可丢弃、不能写脏”的做法,一开始业务方完全不接受,觉得丢数据是损失。但对比一下就知道:写进去一条不完整的数据,模型会用错误信息干活,损失是持续的、不可控的;丢弃最多就是这一秒的特征缺失,模型走默认值,损失是一次性的。两相权衡,丢弃更明智。
4. 模型部署落地:GPU服务、容器化LLM与边缘设备
4.1 在线推理服务的分层设计
特征存储解决了“喂给模型什么”,模型部署解决的是“模型怎么跑起来”。
我们在线推理服务的架构分了三层:特征拼接层、模型推理层、后处理层。特征拼接层通过特征存储客户端拉取实时特征,输出一个统一的特征向量;模型推理层接收特征向量,跑模型计算;后处理层做分数修正、阈值过滤等业务逻辑。
这三层必须独立部署、独立扩缩容。刚开始我们图省事把三层打包在一个服务里,结果一次推荐流量脉冲直接打满CPU,特征拼接和模型推理互相拖累,排查问题时还得在一个进程里同时看两套日志。拆开之后,各自扩缩容、各自监控,故障半径小了很多。
对于常规深度学习模型,推理层我们用Triton Inference Server做底座。它支持多模型并发、动态batch、模型热加载。有一个比较细节的经验:Triton的动态batching效果非常依赖请求的到达模式,如果上游请求本身就有明显的毛刺,动态batch的收益会打折扣。后来我们在特征拼接层做了一个轻量的请求攒批逻辑,把几十毫秒内的请求聚合到一批再发Triton,GPU利用率从30%提到了70%左右。
4.2 LLM部署的轻量路径:gguf、ollama与Docker
LLM的部署需求是在今年初猛然出现的。业务方想上线一个智能客服助手,但数据合规要求模型必须本地部署,不能用云API。我们调研了几条路径,最后用ollama + gguf + Docker这条链路跑通的。
GGUF是llama.cpp推的一种模型量化格式,好处是它把模型量化、权重分片、tokenizer配置都打包在单文件里,配合llama.cpp能充分利用CPU和GPU混合推理,对消费级显卡和内存都比较友好。
我们在内网一台GPU服务器上用Docker部署ollama的流程很简单:
docker run -d \ --gpus all \ -v /mnt/models:/root/.ollama/models \ -p 11434:11434 \ --name ollama \ ollama/ollama拉取模型:
ollama pull qwen2.5:7b-instruct-q4_K_M这里q4_K_M是量化级别,K_M优于K_S,保留精度更好,模型文件也不大。7B模型Q4量化大约4.7G字节,一块L20显卡轻松跑。
为什么要用Docker?最直接的理由是版本隔离和环境一致性。ollama版本升级频繁,Python依赖和CUDA版本动不动就冲突,容器化之后升级就是换个镜像,回滚也方便。另一个理由是端口和进程管理:ollama服务如果直接裸进程跑,一旦崩溃没个自动恢复;用Docker跑,可以通过--restart=always策略自动拉起,省了写systemd unit的功夫。
Windows端部署也是同样的思路,Docker Desktop支持GPU直通之后,Windows上跑模型基本就是拉镜像、跑容器、映射端口三步。需要注意的只有一点:WSL2的显存分配机制,Windows上Docker无法直接访问宿主机GPU显存,需要确保WSL2内核版本支持CUDA,否则会出现“容器起了但GPU用不上”的怪问题。
4.3 L20显卡的定位:为什么它成了内部最抢手的部署卡
部署LLM过程中我们对比过几块卡,最后内部的结论是L20成了性价比之王。
L20是48G显存的中端数据中心GPU,它的定位很有意思:显存够大,能装下7B到13B模型的量化版本(13B Q4大约9G,13B Q8大约14G,都有余量),功耗相对低,价格比A100/H100便宜一个量级。对我们这种推理需求远大于训练需求的场景来说,训练卡是浪费的,L20反而刚好。
| 显卡 | 显存 | 功耗 | 适合场景 | 备注 |
|---|---|---|---|---|
| L20 | 48G | 275W | 7B~14B模型推理 | 性价比最高 |
| A10 | 24G | 150W | 7B以下量化模型 | 显存偏小 |
| A100 80G | 80G | 300W | 大模型训练+推理 | 成本太高 |
| RTX 4090 | 24G | 450W | 个人开发调试 | 不适合机房 |
我们在L20上跑的典型配置是:Qwen2.5-7B-Instruct GGUF Q4量化,--num-gpu全量加载,并发16路请求,单路生成速度能达到40~50 token/s,这个吞吐对内部客服场景绰绰有余。
有一个部署细节值得说:ollama默认会用GPU推理但不是所有算子都上GPU,有一些算子会回退到CPU,导致整体速度被拖慢。可以在启动时设置环境变量OLLAMA_GPU_OVERLAP=1(控制GPU和CPU重叠计算)和OLLAMA_MAX_LOADED_MODELS=1(只保留一个常驻模型)。前者提升吞吐,后者避免多个模型来回换入换出导致显存抖动。
4.4 边缘部署案例:树莓派5上的YOLOv5
如果说L20是机房的“甜点卡”,那树莓派就是彻头彻尾的“极限挑战”。我们有个业务场景需要在门店设备端做实时货架检测,网络条件不稳定,必须设备端完成推理。
树莓派5的算力相比4代强不少,但跑YOLOv5s原生模型还是吃力。我们的完整部署链路如下:
- 在PC上训练并导出ONNX模型。
- 用ONNX Runtime跑推理,但树莓派是ARM架构,ONNX Runtime的ARM版本有,但优化明显不如x86。
- 测试发现mAP无损但推理速度只有2~3 FPS,显然不够。
- 换成YOLOv5s的nano版本,再叠加int8量化(用
torch.ao.quantization做量化感知训练),最终推理速度提升到9~10 FPS,勉强可用。
更进一步的优化是把模型转成NCNN格式——NCNN对ARM架构的优化比ONNX Runtime激进很多,同样的nano模型在树莓派5上跑到16~18FPS,而且内存占用低不少。但NCNN的转换和算子支持是个门槛,很多相对新的注意力机制层不一定有现成实现,需要手工写算子或者裁剪模型结构。
如果你也在做树莓派部署YOLO系列,我的建议是提前确认使用场景对FPS的要求,10FPS接受的话走ONNX Runtime最省事;低于10FPS完全不可接受,那就要考虑量化+NCNN双管齐下,实在不行只能换更轻量的模型(比如YOLOv8n)。
5. 特征仓库到线上推理的闭环:版本对齐与灰度发布
5.1 特征版本、模型版本与数据版本的三元组对齐
有了特征存储之后,我们会把每一次训练任务的信息记录成一个不可变的训练任务记录,包含三个关键版本:
- 模型代码版本(Git commit hash)。
- 特征Schema版本(特征注册中心的配置版本号)。
- 数据版本(训练数据在Iceberg里的快照时间范围)。
这三个版本缺一不可。以前出现过一种情况:模型代码回滚到上一版,但特征Schema已经变了,结果老模型吃新特征,推理效果异常却不知道怎么排查。有了版本记录,每次模型上线前只需要比对当前生产环境的特征Schema版本和训练记录里的特征Schema版本是否一致,这个检查点由部署平台自动执行,不一致直接阻断发布。
这个三元组对齐思路,本质上把模型部署从“部署一个模型文件”变成了“部署一组可追溯的产物”。模型文件、特征配置、预处理代码、后处理配置一起打包成一个部署单元,回滚时整体回滚,避免只回滚模型却留下新特征的尴尬局面。
具体实现上,我们为每个部署单元生成了一个manifest.yaml,文件大概长这样:
model: name: rec_rank_v1 version: 2025-06-01-10-30 artifact_path: hdfs:///models/rec_rank_v1/2025-06-01-10-30/ feature: schema_version: 128 online_backend: redis-cluster-01 data: training_window: [2025-05-01, 2025-05-31] iceberg_snapshot: snapshot-202506011200部署平台读到这个文件,自动校验当前生产环境的特征Schema版本是否为128,不是就拒绝上线。这套机制上线后,因为特征不一致导致的发布事故数量直接降为零。
5.2 影子评估与灰度发布:让流量替你做决策
再充分的离线验证都不如一小撮真实流量说话。我们模型上线的标准流程是:影子评估 → 金丝雀发布 → 全量切流。
影子评估阶段,线上服务会复制一部分真实请求流量,同时送给当前生产模型和待上线的候选模型,候选模型的结果只记录不返回给用户。这个阶段我们主要看候选模型的AUC、点击率预估分布、特征覆盖率是否有明显偏差。
这里有一个比较隐蔽的技术点:影子流量的特征输入必须和生产模型完全一致,否则对比没有意义。我们通过特征存储的一个标志位控制,影子评估期间候选模型也走同一个特征读取链路,保证拿到一模一样的特征向量。
金丝雀发布阶段,一般按5%流量起步,观察核心业务指标和系统指标(延迟、GPU利用率、推理错误率)24小时,没问题再逐步扩大到30%、100%。中间如果发现异常指标,自动回滚到上一个稳定版本,整个过程不回代码、不重启服务,只做流量切换。
5.3 在线特征监控:命中率与新鲜度的实时看板
最后一个容易忽略的环节是特征监控。没有监控,特征存储就是一个“看起来正常但随时会爆炸”的定时炸弹。
我们监控的核心指标有三个:
特征命中率:线上推理时,请求的特征在Redis里实际命中的比例。命中率突然下降,通常意味着特征写入任务挂了或者延迟。一般要求命中率不低于99%。
特征新鲜度:每个特征最近一次写入的时间距当前时间的差距。比如T0特征延迟预算30秒,如果连续5分钟实际延迟超过60秒,直接报警。
特征分布漂移:用近30天特征值分布的PSI(Population Stability Index)和当日分布做对比。PSI超过阈值说明特征数值分布发生明显变化,可能是上游业务变化,也可能是数据管道bug。
这三个指标的看板我们直接接在Grafana上,特征管道和模型服务团队各看各的,但报警都汇总到同一个值班群。曾经有一次Redis集群内存淘汰策略触发,大量冷特征被LRU挤出,命中率快速降到80%以下,如果不是命中率看板及时报警,业务损失可能会持续好几个小时。
6. 部署踩坑实录与性能优化备忘
6.1 特征缓存中的序列化陷阱
在线推理服务为了降低延迟,会在本地进程内再包一层特征缓存。这个设计本身没错,但坑出在序列化方案上。
我们最初用Java的序列化直接存特征对象,Redis里用的是Fastjson JSON字符串。某天上线新特征后,缓存反序列化直接抛异常,排查发现新特征的一个字段包含了一个特殊字符,Java序列化没问题,但Fastjson在解析时把它解析成了控制字符,直接报错。后来所有特征缓存统一改用Protobuf序列化,序列化性能提升了,最重要的是Schema强约束,什么类型能存、什么类型不能存,编译期就挡住了,运行时不会再遇到这类问题。
另一个缓存陷阱是缓存穿透。特征命中率再高,总有miss的那部分请求需要回源查Redis,如果某一秒突然涌入大量冷门实体特征,Redis连接池会被瞬时打满。我们在本地缓存层加了一个互斥锁:同一个实体ID的缓存miss只允许一个线程回源,其他线程等待结果,这个简单设计直接让Redis的峰值QPS降了一半。
6.2 模型部署中的显存与并发调优
部署LLM时,我们一边在L20上跑客服模型,一边排查一个诡异的现象:推理延迟不高,但GPU利用率始终上不去。后来发现是并发设得太保守,默认的并发路数才4路,而客服场景的请求本身很稀疏,GPU大部分时间在等待。把并发调到16路之后,GPU利用率终于到了70%以上。
但并发调高之后又出来一个新问题:上下文窗口拉满时,七B模型输出逐渐变慢,因为attention算子的计算量随输出长度平方级增长。我们的兜底策略是给每条请求设置最大输出token数,客服场景默认512,实际95%的回复在200以内,但有了上限就能防止个别长输入拖垮整个服务。
Triton场景下的显存优化也有话说。动态batch虽然提升了利用率,但batch过大会让单条请求的延迟飙升。我们最后的方法是加了延迟阈值控制:攒批最多等300毫秒,要么凑满batch,要么超时就带着当前这批先出去。在延迟和吞吐之间,这种“以时间换吞吐,但不无限等”的方式最实用。
6.3 Windows环境部署GPU模型的额外注意点
因为团队里有不少开发同学的电脑是Windows,本地调试模型时会遇到一些Linux上不会出现的问题。这里分享三个最容易卡住的地方。
第一是NVIDIA容器工具包和WSL2的配合。Windows下如果通过Docker Desktop跑GPU容器,需要确保宿主机安装的驱动直接支持WSL2的CUDA转发,可以在容器里手动执行nvidia-smi确认是否能识别到显卡。识别不到时,绝大多数情况是WSL2内核太旧,升级内核即可。
第二是显存分配冲突。Windows上本地显卡同时要接显示器,WSL2默认会分配一部分显存给图形应用,留给容器的显存不是全部。想摸清真实的可用显存,在WSL2里执行nvidia-smi看的是虚拟显存,和实际按时有偏差。经验上是实际可用按物理显存打折计算,留出10%~20%的余量。
第三是路径挂载问题。Docker Desktop的Windows路径挂载到Linux容器,如果模型文件放在NTFS盘且路径带中文或空格,容器内路径解析经常出错。我们统一约定:模型文件只放英文路径,且放在User目录下不走C盘系统保护目录,能省掉一半奇奇怪怪的权限问题。
6.4 一次真实故障的排查链路:从报警到根因的两小时
这部分用一次真实经历,把整套体系的协同工作方式串一遍。
某天下午3点,特征命中率看板报警,从99.2%骤降到87%。值班同学第一时间看的是特征管道任务状态,Flink实时任务显示运行中,但Kafka消费延迟从几秒涨到了五分钟。紧接着看Redis监控,发现内存使用率接近上限,旧key正在被大量淘汰。
进一步追查,发现前一天上线了一个新的“POI热度”特征组,误配置成T0级,直接写Redis。但POI的基数远大于用户实体基数,特征数量暴增,直接把Redis内存挤爆,触发了LRU淘汰,把其他核心特征挤出去了。
根因是特征分级配置失误,而不是硬件资源不足。临时方案是把POI特征组降为T1级,改用离线预计算每小时同步一次,冷门POI不下发到Redis。长期修复是给每个特征组加了一个“预计条目数”字段,特征管道注册时预检Redis总容量,如果新增特征组的预计条目数超过容量阈值的30%,直接拒绝上线,必须有架构师审批才能放行。
这个故障的启示是:平台能力再强,也拦不住配置的随意性。特征存储和部署链路要做到“默认安全”,而不是“默认自由、事后打补丁”。
最后说两句
写到这里,把特征存储和模型部署从设计到落地的主要环节都过了一遍。我个人这两年最大的体会是,做机器学习平台,与其说是在解决算法问题,不如说是在解决工程治理问题。特征存储解决的是模型“吃什么”的一致性和可追溯性,模型部署解决的是模型“怎么跑”的稳定性和可回滚性。这两块一旦夯实,算法团队的工作重心才能真正回归到特征探索和模型优化本身,而不是天天为管道问题灭火。
如果你正准备在团队里推动类似建设,我的建议是从线上事故复盘开始,让业务方看到“特征不一致”和“部署回滚难”带来的真实成本,再立项做平台,阻力会小很多。技术方案本身反而不是门槛,关键是让所有人理解这件事为什么值得做。