从“慢到裂开”到“丝滑如德芙”:一次推荐系统高并发实战的血泪复盘
坐标杭州,阿里网易扎堆的天堂软件园。早上8点,我已经坐在工位上泡好第三杯美式——别笑,这不是卷,是被双11压出来的生物钟。
大家好,我是小红书干了两年推荐算法的工程师,日常主业是调模型、刷指标、和产品经理battle需求边界(懂的都懂),副业是对用户增长有点执念,顺带研究点分布式系统底层。今天这篇不是水文,而是上周五深夜上线后我靠咖啡续命三天的真实复盘:如何在流量暴涨300%的情况下,把一个“慢到裂开”的推荐服务干成“丝滑如德芙”。
事情还得从去年双11说起。
一、背景:当“惊喜”变成“惊吓”
去年双11前夕,我们搞了个新功能——个性化首页瀑布流。说白了,就是每个用户刷首页看到的内容都不一样,背后跑的是我们团队打磨半年的多目标排序模型(CTR + 时长 + 转化率加权)。产品同学拍胸脯说:“这次DAU能涨20%!”
结果呢?
上线当天中午12点,监控大盘直接爆红。QPS从平日的5k飙到18k,P99延迟从80ms飙到1.2s。用户反馈炸锅:“刷个首页比我煮泡面还慢?”、“以为我手机坏了,重装三次APP”。
运维同事在钉钉群疯狂@我:“兄弟,再不降负载,K8s要自动熔断了!”
测试妹子幽幽补刀:“你们算法是不是又在for循环里调RPC了?”
我???
当时真想砸电脑。但转念一想——这不正是跳槽前练手高并发架构的绝佳机会吗?(没错,我确实在看机会,但不能让老板知道 😏)
二、问题定位:慢在哪?不是模型,是链路!
第一反应肯定是“模型太重”,毕竟我们用的是双塔+MMoE,参数量不小。但 profiling 一看,模型推理只占整个请求耗时的35%,剩下65%全耗在I/O和串行调用上:
- 用户特征拉取:要查5个微服务(用户画像、历史行为、实时兴趣、社交关系、地域标签)
- 内容池召回:先粗排召回2000 item,再精排打分
- 每次 RPC 调用平均 40ms,串行调用 → 200ms+
更离谱的是,有些服务居然还在用 HTTP/1.1,TCP 连接没复用,每次都要 handshake。这哪是推荐系统,这是“等待系统”吧?
技术债就像信用卡,刷的时候爽,还款的时候哭。
三、重构思路:异步 + 缓存 + 向量化
3.1 异步并行拉特征(告别 for 循环 RPC)
以前的代码大概是这样(别骂了,真是我写的):
def get_user_features(user_id):
profile = user_profile_service.get(user_id) # 40ms
history = behavior_service.get(user_id) # 40ms
interest = real_time_interest.get(user_id) # 40ms
social = social_graph.get(user_id) # 40ms
geo = geo_tag_service.get(user_id) # 40ms
return merge(profile, history, interest, social, geo)
串行 5 次调用 → 200ms 起步。
改成 asyncio 并行后:
import asyncio
async def fetch_profile(user_id):
return await user_profile_service.async_get(user_id)
async def fetch_history(user_id):
return await behavior_service.async_get(user_id)
# ... 其他类似
async def get_user_features_async(user_id):
tasks = [
fetch_profile(user_id),
fetch_history(user_id),
fetch_interest(user_id),
fetch_social(user_id),
fetch_geo(user_id)
]
results = await asyncio.gather(*tasks)
return merge(*results)
效果立竿见影:特征拉取从 200ms 降到 55ms。因为网络 I/O 是可并行的,CPU 等着也是等着,不如一起发。
提示:我们内部服务已全面切换 gRPC + HTTP/2,连接复用 + 多路复用,比 HTTP/1.1 快不止一点。
3.2 本地缓存 + Redis 多级缓存
有些特征变化不频繁(比如用户性别、注册城市),但每次都要查 DB,纯属浪费。
我们在服务本地加了一层 LRU Cache(TTL=5min),配合 Redis 做二级缓存:
from cachetools import TTLCache
user_feature_cache = TTLCache(maxsize=100_000, ttl=300) # 5分钟
async def get_user_features_cached(user_id):
if user_id in user_feature_cache:
return user_feature_cache[user_id]
features = await get_user_features_async(user_id)
user_feature_cache[user_id] = features
return features
同时,对热点用户(比如明星、KOL)做 Redis 预热。双11当天,缓存命中率达 82%,DB QPS 直接砍掉一半。
3.3 向量化推理:别再一条条 predict 了!
最让我脸红的是:早期为了“快速上线”,我们居然是单条样本送入模型预测。1000 个 item → 调 1000 次 model.predict()。
这操作堪比用勺子舀西湖水灭火。
后来改用 batch inference,把整个候选集打包成 tensor 一次性过模型:
# 原来(反面教材):
scores = []
for item in candidates:
feat = build_feature(user_feat, item)
score = model.predict([feat])
scores.append(score)
# 改造后:
features = [build_feature(user_feat, item) for item in candidates]
batch_tensor = torch.tensor(features) # shape: [N, D]
scores = model(batch_tensor).squeeze().tolist() # 一次推理搞定
配合 ONNX Runtime + TensorRT 加速,推理时间从 300ms 降到 45ms,GPU 利用率反而更稳了。
四、技术选型:为什么选这些方案?
很多人问:“为啥不用 Flink 实时特征?为啥不用 Faiss 向量召回?”
现实很骨感:我们不是 Google,没有无限资源。
| 方案 | 优点 | 缺点 | 我们的选择 |
|---|---|---|---|
| Flink 实时特征 | 低延迟 | 开发成本高,运维复杂 | ✘ 暂不引入 |
| 本地 LRU + Redis | 简单、快、可控 | 缓存一致性难 | ✔ 主力方案 |
| gRPC vs HTTP/1.1 | 高性能、强类型 | 需要 IDL 定义 | ✔ 已全面迁移 |
| Batch Inference | GPU 利用率高 | 需 padding 对齐 | ✔ 必须做 |
我们团队信奉一个原则:能用工程手段解决的,绝不堆人力;能用缓存扛住的,绝不加机器。
五、线上效果:数据不会骗人
经过两周迭代 + 三次灰度发布,最终效果如下:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| P99 延迟 | 1200ms | 110ms | ↓ 91% |
| 服务错误率 | 3.2% | 0.05% | ↓ 98% |
| GPU 利用率 | 70% (波动大) | 45% (稳定) | 更省成本 |
| 用户停留时长 | - | +12% | 业务侧狂喜 |
最爽的是,双11当天晚上,我居然10点就下班了!要知道往年这个时候,我还在机房和 SRE 一起查日志。
六、开发心得:那些年踩过的坑
6.1 缓存雪崩:别让 TTL 全一样
第一次上线时,我把所有缓存 TTL 设成 300s。结果 5 分钟后,10w 请求同时击穿缓存,DB 直接挂了。
后来改成 随机 TTL(280~320s),再加互斥锁重建缓存:
if not cache.get(key):
with redis.lock(f"lock:{key}", timeout=5):
if not cache.get(key): # double check
val = load_from_db()
cache.setex(key, random.randint(280, 320), val)
6.2 异步陷阱:别在 async 里混 sync
有次为了“兼容老代码”,我在 async 函数里偷偷调了 requests.get()(同步 HTTP)。结果 event loop 被 block,整个服务假死。
教训:async 生态要彻底,要么全 async,要么全 sync。我们后来用 httpx 替代 requests,一步到位。
6.3 监控必须细粒度
光看 P99 不够,要拆到每个子模块耗时。我们现在用 OpenTelemetry 埋点,连“特征拼接耗时”都能追踪。
七、写在最后:技术人的浪漫
有人说,推荐系统就是“猜你喜欢”。但我觉得,真正的推荐,是让用户感觉不到“猜”的过程——滑得越顺,越觉得“这 APP 懂我”。
而作为工程师,我们的使命不是堆砌 fancy 的模型,而是在高并发、低延迟、高可用之间找到那个微妙的平衡点。
这次优化后,产品同学请我喝了杯瑞幸(就一杯,抠门)。但没关系,看到用户评论“最近刷得好流畅”,比啥都值。
对了,如果你也在杭州搞推荐/增长,欢迎交流~(顺便,内推阿里网易的岗位也接,别问,问就是“朋友公司缺人” wink)
实战经验总结:
- 异步并行是 I/O 密集型服务的银弹
- 缓存不是万能的,但没有缓存是万万不能的
- 向量化推理是 GPU 服务的必修课
- 监控要细到“毛细血管”,否则出事只能盲人摸象
开发心得:
别为了“快”而牺牲“稳”,线上事故的代价远高于开发时间。
技术债可以欠,但利息很高——趁早还,别等双11催收。
最后送大家一句我工位贴的座右铭:
“You build it, you run it.” —— 别写出自己都救不了的代码。
共勉。

评论 0