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

背景介绍
去年我参与了一个项目,是为公司核心产品“首页推荐”模块做一次架构升级。原来的推荐服务是一个单体 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 | 支持灰度发布、自动化扩缩容 |

这里特别提一下为什么选择 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))
}()

// 回退方案
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