技术探索与实践的一些思考

技术布道者
2025-06-21 21:15
阅读 419

技术探索与实践的一些思考:从零到一构建一个高并发推荐服务

技术探索与实践的一些思考:从零到一构建一个高并发推荐服务

背景介绍

去年我参与了一个项目,是为公司核心产品“首页推荐”模块做一次架构升级。原来的推荐服务是一个单体 Java 应用,运行在物理机上,数据依赖 MySQL 和 Redis 的组合。随着用户量和行为数据的增长,旧系统已经逐渐暴露出以下几个问题:

  • 响应延迟越来越高,P95 已经逼近 500ms
  • 无法快速迭代新算法逻辑
  • 部署维护成本高,扩缩容不灵活
  • 难以支撑个性化推荐的实时性需求

我们的目标很明确:打造一套新的推荐服务,能够支持高并发、低延迟、可拓展性强、并且可以灵活接入机器学习模型的能力。

在这个过程中,我们经历了技术选型、架构设计、编码实现、上线调试等一系列挑战。现在回想起来,有很多值得总结和分享的地方。


遇到的问题和挑战

1. 高并发下的性能瓶颈

最开始我们只是把原来的服务做了微服务化拆分,迁移到了 K8s 上,但发现 QPS 稍微上去一点就出现大量超时。通过分析日志,发现有几个点比较突出:

  • 数据库连接池打满
  • 多个线程频繁阻塞等待数据库返回结果
  • Redis 单点读取压力大,网络延迟成为瓶颈

这说明原来的架构已经不适合当前的流量规模了,必须进行更彻底的重构。

2. 个性化推荐实时性的挑战

我们想引入一些基于行为数据的实时推荐能力,比如根据用户的最近点击来调整推荐内容。这部分逻辑对实时性要求很高,需要毫秒级的响应速度,传统的同步查询方式根本撑不住。

这时候我们开始考虑是否可以采用异步处理 + 缓存预加载的方式,并尝试引入一些流式计算框架。

3. 模型推理服务对接难题

我们还计划接入一个基于 TensorFlow 的轻量级推荐模型做排序,但在实际对接中遇到了几个问题:

  • 如何保证模型版本管理和热更新?
  • 推理服务如何高效调度,避免冷启动?
  • 如何衡量模型效果并在 AB 测试中快速切换?

这些都不是写个 RPC 接口就能解决的。


解决方案和技术选型

面对这些问题,我们在多个维度做了改进和优化。

整体架构设计

我们采用了“前置缓存 + 后置异步打分 + 模型服务独立部署”的架构。

用户请求 → Nginx → 推荐服务 → Redis 预缓存(命中)→ 返回结果  
                                  ↓(未命中)  
                          异步拉取候选集 → 实时打分 → 写入缓存

这种结构大大缓解了主线程的压力,同时也保持了推荐结果的实时性。

关键技术栈选择

组件 选择理由
Go 性能优秀,协程模型天然适合高并发场景
Redis Cluster 支持水平扩展,应对高读压力
Kafka 实时行为数据采集和下游异步处理
Flink 流式计算引擎,用于实时特征提取
TensorFlow Serving 管理模型版本、支持多种部署方式
Istio/K8s 支持灰度发布、自动化扩缩容

系统架构设计-1

这里特别提一下为什么选择 Go。虽然原系统是 Java 写的,但我们做了压测对比后发现,在相同资源下,Go 的 QPS 提升了接近 3 倍,且内存占用更低。这对于云环境下的成本控制非常关键。


核心代码片段示例

下面是一段简化版的核心逻辑代码(省略了部分错误处理和封装细节):

// 获取推荐列表主流程
func GetRecommendations(ctx *gin.Context) {
    userID := ctx.Query("user_id")
    
    // 先查缓存
    cached, err := redisClient.Get(fmt.Sprintf("rec_cache:%s", userID))
    if err == nil && cached != "" {
        ctx.JSON(200, parseCachedResult(cached))
        return
    }

    // 异步获取候选集并打分
    go func() {
        candidates := fetchCandidates(userID)
        scored := asyncScoreWithTFService(candidates)
        redisClient.Setex(fmt.Sprintf("rec_cache:%s", userID), 60, marshal(scored))
    }()


![技术概念图解-2](https://code-guide.oss.shanghai.autogptai.club/common/file/download?name=date2025062121/2bc9d98a-c4fb-4e9f-a624-8e36b4de4b7b.jpg)


    // 回退方案
    fallback := getFallbackList(userID)
    ctx.JSON(200, fallback)
}

这段代码体现了我们“缓存优先 + 异步补全”的设计思路。当然实际生产中会涉及更多细节,比如打分失败兜底策略、降级开关等。


踩坑经验分享

1. Redis 连接池配置不合理导致雪崩

一开始我们为了简化运维,使用了共享的一个 Redis 集群,结果在线上高峰期发生过一次“缓存失效雪崩”。

后来我们改为:

  • 对每个核心业务隔离不同的 Redis 实例
  • 控制连接池最大连接数 + 启动熔断机制
  • 设置缓存过期时间随机值(+/- 5%)

才解决了这个问题。

2. 模型服务冷启动慢影响首请求体验

最初模型服务在 Kubernetes 中设置的是懒加载,即只有第一次请求触发模型加载。但这样会导致用户首次访问时延迟极高。

解决方案是:

  • 使用 initContainer 提前下载模型文件
  • 在 deployment 中加入 warm-up 探针,确保 Pod ready 前完成模型加载
  • 搭建 AB 测试通道,新模型先走影子流量测试

3. 误将同步调用嵌套在 Goroutine 中引发 panic

有一段时间线上不断有 panic: send on closed channel 的异常,排查了很久才发现,我们在异步 goroutine 中不小心嵌套了某个 channel 的关闭操作。

教训是:

  • 尽量避免多层 channel 嵌套
  • 使用 context 控制生命周期
  • 所有并发操作必须加 recover 并打印堆栈

实施后的效果和收益

项目上线之后,我们在多个指标上都有明显提升:

指标 上线前 上线后
P95 延迟 480ms 95ms
支持 QPS ~8k ~32k
模型更新耗时 1小时 <5分钟
新功能上线周期 1-2周 3天以内
运维复杂度 中等

不仅提升了用户体验,也为我们后续尝试更多 ML 应用打开了空间。


经验总结与建议

作为开发者,我想分享几点在这次实践中积累下来的体会:

✅ 技术选型要结合实际业务负载和团队熟悉度

不要为了追求新技术而忽略落地成本。比如我们原本想尝试 Rust,但由于团队成员普遍对 Go 更熟,最终选择了后者。

✅ 架构设计要考虑可演进性,而不是一步到位

我们没有一开始就引入太多复杂的组件,而是先重构服务,再逐步引入 Flink 和 TF Serving。这种方式更容易验证、控制风险。

✅ 性能优化要抓住主要矛盾

很多时候你不需要搞什么黑科技,只要找到瓶颈点,针对性地解决就可以大幅提升整体表现。

✅ 写代码要有“防御意识”

尤其是在并发场景下,任何一个 goroutine 的 panic 都可能导致整个服务崩溃。所以:

  • 必须加 recover
  • 不要轻易共享状态
  • 日志要够详细,便于定位问题

✅ 多关注业界趋势,但别盲目跟风

比如现在大家都在谈向量数据库、AI agent,但我认为对于大多数公司来说,先把基础链路做到稳定可靠才是更重要的。


写在最后

这次项目让我深刻体会到,技术的价值从来不只是“能不能实现”,而是在“如何优雅又低成本地实现”上做出权衡

开发不是一个人闭门造车,而是不断和产品经理、测试同学、运维同事沟通协调的过程。每一个决策的背后,都是无数个小时的讨论和实验。

希望这篇文章能给你带来一些启发,也欢迎在评论区交流你的经验和看法。

评论 0

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