从“慢到裂开”到“丝滑如德芙”:一次推荐系统高并发实战的血泪复盘

模型调用员
2025-12-15 15:49
阅读 214

坐标杭州,阿里网易扎堆的天堂软件园。早上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

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝