技术探索与实践的一些思考
上个月底,我刚从网易游戏跳槽到一家新公司,入职满两个月。之前在网易做服务端开发,参与过两款上线手游的后端架构,天天和Redis、Kafka、Protobuf打交道,也经历过凌晨三点被PagerDuty叫醒修线上事故的“高光时刻”。现在这家创业公司节奏更快——上周五晚上十点,产品经理又发来消息:“这个功能能不能下周上线?用户反馈很急。” 我一边听着Lo-fi Hip Hop写代码(别笑,这玩意儿真能提神),一边默默在心里翻了个白眼。
但说真的,我挺享受这种“边打地鼠边搭积木”的状态。技术这东西,不逼一逼自己,永远停在舒适区。今天想聊聊最近在新项目里踩的坑、做的技术选型,以及一些不成体系但血泪换来的开发心得。
问题来了:产品要“实时”,但服务器快炸了
事情起于一个再普通不过的需求:产品团队想做一个“实时排行榜”功能,支持百万级玩家在线更新积分,并且要求延迟低于500ms。听起来人畜无害对吧?但现实是——我们的旧架构用的是MySQL主从同步 + 每分钟批量更新,查询一次要2秒多,用户早跑光了。
更离谱的是,产品经理在需求评审会上轻描淡写地说:“隔壁竞品都能做到毫秒级刷新,我们是不是太慢了?”
我心想:人家可能用了Redis Sorted Set + 分片集群,而你连缓存都没配齐……
于是,任务落到了我头上:两周内重构排行榜系统,支持高并发写入、低延迟读取,还要兼容老接口。Deadline压得像高考前夜,而我当时对Redis集群的运维经验几乎为零——在网易时这类基础设施都是中间件团队包办的,我们只管调API。
这回,得自己动手了。
踩坑实录:从“我以为”到“我裂开了”
第一坑:Sorted Set 真的万能吗?
一开始我想当然:用 Redis 的 ZADD 和 ZREVRANGE 不就完事了?简单、高效、社区案例一堆。结果压测第一天就翻车。
我们模拟了 10w QPS 的写入,单个 Redis 实例 CPU 直接飙到 95%,响应时间从 5ms 飙到 800ms。原因很简单:Sorted Set 的底层是跳跃表(Skip List),插入复杂度 O(logN),但当 N 达到百万级时,logN 也不小了,加上频繁的内存分配和淘汰策略触发,性能断崖式下跌。
当时真的想砸键盘。但转念一想:网易那款MMO的公会战排行榜也是类似场景,他们是怎么扛住的?
回忆起来,当时是用了分桶(Bucketing)+ 异步聚合的策略。于是我也照葫芦画瓢:把全服玩家按 ID 取模分成 100 个桶,每个桶一个 Sorted Set。这样单个 ZSET 最多 1w 元素,插入快如闪电。读取时并行查 100 个桶,再用堆归并 TopK —— 虽然网络开销多了点,但总比卡死强。
# 伪代码:分桶写入
def update_score(user_id, score):
bucket_id = user_id % 100
redis.zadd(f"leaderboard:{bucket_id}", {user_id: score})
# 读取 Top 100:并行获取各桶 top 100,再全局排序
async def get_global_top_100():
tasks = [redis.zrevrange(f"leaderboard:{i}", 0, 99, withscores=True) for i in range(100)]
results = await asyncio.gather(*tasks)
# 合并 + 堆排序取 Top 100
all_items = [item for sublist in results for item in sublist]
return heapq.nlargest(100, all_items, key=lambda x: x[1])
这一招下去,写入 P99 降到 15ms,读取 P99 120ms,勉强达标。
第二坑:数据一致性怎么保?
新系统上线后,测试同学立刻甩来一个 Bug:玩家 A 积分更新了,但排行榜没变。一查日志,发现 Redis 写成功了,但 MySQL 主库还没同步——因为我们的积分变更先写 DB,再发 MQ 触发排行榜更新。如果 MQ 消费延迟或失败,数据就丢了。
这属于典型的最终一致性陷阱。在网易时,我们用的是 Binlog 监听 + 补偿 Job,但新公司没这套设施。临时方案?加 retry + 死信队列。但治标不治本。
最后我咬牙上了 Redis Streams + 消费组,把排行榜更新逻辑做成幂等的消费服务:
# 生产者:积分变更后推流
XADD leaderboard_updates * user_id 12345 score 9876
# 消费者:独立服务监听 stream,保证至少一次交付
XREADGROUP GROUP lb_consumer_group $ COUNT 100 STREAMS leaderboard_updates >
配合数据库的 version 字段做乐观锁,终于把数据丢失率压到 0.001% 以下。虽然架构复杂了点,但胜在可靠。
技术选型:不是越新越好,而是“刚刚好”
很多人一听到“实时排行榜”,马上想到 Flink、ClickHouse、甚至自研时序数据库。但对我们这种中小团队来说,过度设计比性能瓶颈更致命。
我们评估了几个方案:
| 方案 | 延迟 | 开发成本 | 运维复杂度 | 是否适合当前阶段 |
|---|---|---|---|---|
| 单 Redis ZSET | 高(>800ms) | 低 | 低 | ❌ |
| 分桶 Redis + 异步聚合 | 中(~120ms) | 中 | 中 | ✅ |
| Flink + RocksDB | 低(<50ms) | 高 | 高 | ❌(人力不够) |
| 自研内存索引服务 | 极低 | 极高 | 极高 | ❌❌❌ |
最后选了“分桶 Redis”方案,核心逻辑三天写完,剩下时间全花在监控和兜底上——比如加了熔断:当 Redis 响应超时,自动降级到 MySQL 缓存视图(虽然慢点,但不至于崩)。
开发心得第一条:技术债可以欠,但不能赖。 我们留了 TODO 注释,等 Q3 有专职中间件工程师后再迁移到更专业的方案。
产品思维 vs 工程思维:一场永恒的拉锯战
有一次站会上,产品 PM 问我:“为什么不能直接用 WebSocket 推送排行榜变化?用户看到数字跳动多爽啊!”
我差点脱口而出:“你知道百万连接的 TCP 内存开销吗?” 但忍住了。转而画了个对比图:
- WebSocket 全量推送:带宽爆炸,客户端还得处理乱序
- 客户端轮询:省服务器资源,但体验差
- 折中方案:客户端每 5s 拉一次,但服务端用 long polling + ETag 判断是否变化
最后产品接受了。其实很多时候,工程师要做的不是 say no,而是提供可行的替代路径。毕竟产品的目标是用户体验,而我们的目标是——别让服务器半夜报警。
总结:在约束中跳舞
这次重构让我深刻体会到:没有银弹,只有权衡。
- 要性能?可能牺牲一致性。
- 要简单?可能放弃扩展性。
- 要快?就得接受技术债。
但在真实业务场景中,这些“不完美”恰恰是工程的魅力所在。就像上周五加班到凌晨两点,终于把最后一个 race condition 修复后,看着 Grafana 上平稳的曲线,耳机里刚好放着《Weightless》——那一刻觉得,值了。
回到开头的问题:为什么要写这篇文章?
一是给自己留个记录,免得半年后又踩同样坑;
二是想告诉同行们:别怕从零开始。我在网易三年,很多底层原理都是“用到才学”。现在到了新环境,照样从 Redis 配置文件一行行啃起。
技术探索从来不是为了炫技,而是为了更好地支撑产品、服务用户。 当你的代码能让百万玩家流畅竞技,那种成就感,比任何技术大会 Keynote 都实在。
对了,刚收到消息:下个版本要上“跨服排行榜”……
我默默打开了 Redis Cluster 文档,顺手切了首歌——《Eye of the Tiger》。
(完)

评论 0