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

技术边角料
2025-12-18 12:02
阅读 1189

上个月底,我刚从网易游戏跳槽到一家新公司,入职满两个月。之前在网易做服务端开发,参与过两款上线手游的后端架构,天天和Redis、Kafka、Protobuf打交道,也经历过凌晨三点被PagerDuty叫醒修线上事故的“高光时刻”。现在这家创业公司节奏更快——上周五晚上十点,产品经理又发来消息:“这个功能能不能下周上线?用户反馈很急。” 我一边听着Lo-fi Hip Hop写代码(别笑,这玩意儿真能提神),一边默默在心里翻了个白眼。

但说真的,我挺享受这种“边打地鼠边搭积木”的状态。技术这东西,不逼一逼自己,永远停在舒适区。今天想聊聊最近在新项目里踩的坑、做的技术选型,以及一些不成体系但血泪换来的开发心得


问题来了:产品要“实时”,但服务器快炸了

事情起于一个再普通不过的需求:产品团队想做一个“实时排行榜”功能,支持百万级玩家在线更新积分,并且要求延迟低于500ms。听起来人畜无害对吧?但现实是——我们的旧架构用的是MySQL主从同步 + 每分钟批量更新,查询一次要2秒多,用户早跑光了。

更离谱的是,产品经理在需求评审会上轻描淡写地说:“隔壁竞品都能做到毫秒级刷新,我们是不是太慢了?”
我心想:人家可能用了Redis Sorted Set + 分片集群,而你连缓存都没配齐……

于是,任务落到了我头上:两周内重构排行榜系统,支持高并发写入、低延迟读取,还要兼容老接口。Deadline压得像高考前夜,而我当时对Redis集群的运维经验几乎为零——在网易时这类基础设施都是中间件团队包办的,我们只管调API。

这回,得自己动手了。


踩坑实录:从“我以为”到“我裂开了”

第一坑:Sorted Set 真的万能吗?

一开始我想当然:用 Redis 的 ZADDZREVRANGE 不就完事了?简单、高效、社区案例一堆。结果压测第一天就翻车。

我们模拟了 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

最热最新
暂无评论
技术边角料Lv.1
0
影响力
0
文章
0
粉丝