凌晨三点半聊高并发,顺便聊聊我最近折腾的那些玩意儿
凌晨三点半,耳机里放着 Lo-Fi Beats,IDE 里跑着一个跑不通的单元测试,手边的咖啡已经凉透了。
我是一名游戏服务端开发,日常就是和 Go、Redis、MySQL 打交道。最近公司在搞一个新项目,DAU 预期要翻三倍,老板在周会上拍着桌子说"系统必须扛住"。好家伙,这一句话,我和组里几个兄弟连着加了快一个月的班。
更烦的是,我还在准备跳槽。白天写业务代码、review、对需求,晚上回去刷 LeetCode、看八股文、研究系统设计。有时候真觉得自己像个陀螺,被生活抽着转。
不过话说回来,这一个月的高并发实战,确实让我对系统设计有了不少新的理解。今天趁着代码在编译,就把这段时间的踩坑和思考整理一下。文章写得比较随意,想到哪写到哪,大家凑合看。
先说说我们遇到了什么问题
我们做的是一款多人在线竞技游戏,服务端核心逻辑用 Go 写的。之前系统架构还算简单:客户端连上来 -> 网关层 -> 逻辑服 -> 数据库。单服撑个几千在线没啥问题。
但新项目要搞跨服匹配、全球同服,还要支持回放系统和实时排行榜。产品经理一句"我们要对标头部产品",直接把 QPS 预期从 5k 拉到了 50k,峰值可能更高。
上周五压测的时候,QPS 到 2w 左右,网关层的连接数就开始飙,逻辑服的 goroutine 数量蹭蹭往上涨,MySQL 的慢查询日志也开始刷屏。当时运维兄弟在群里 @ 我:"哥,你这服务是不是有内存泄漏?"
我一看监控,好家伙,确实有几个地方的 goroutine 没正常回收。再加上数据库连接池配置不合理,高并发下直接打满了。
那一刻,我是真想砸键盘。
高并发设计,到底在干什么?
说白了,高并发系统设计就是在回答一个问题:当大量请求同时涌过来的时候,怎么让系统不崩、不卡、不丢数据?
听起来简单,做起来全是坑。我结合这段时间的实践,从几个维度聊聊。
1. 分层与解耦:别让一个服务扛所有
之前我们的服务是"大泥球"架构,一个逻辑服干所有事:匹配、战斗结算、排行榜、邮件系统……全揉在一起。这就像让一个人同时炒菜、洗碗、带孩子,迟早要崩。
我们做的第一件事就是拆。
客户端 -> 网关层(接入+鉴权) -> 匹配服务 -> 战斗服务 -> 结算服务
| | |
Redis Redis MySQL
| | |
排行榜服务 回放服务 邮件服务
拆完之后,每个服务独立部署、独立扩容。匹配服扛不住?加机器。结算服慢?单独优化。互不影响。
这里有个坑要提醒大家:拆服务不是目的,解耦才是。如果你拆了一堆服务,但它们之间还是强依赖、同步调用,那跟没拆区别不大。我们后来大量引入了消息队列(用的 Kafka),把同步调用改成异步消息,系统吞吐量直接翻了一倍多。
2. 缓存策略:Redis 不是万能的,但没它万万不能
游戏服务端几乎离不开 Redis。我们的排行榜、在线状态、匹配队列、session 信息全放 Redis 里。
但缓存这东西,用好了是神器,用不好就是灾难。
踩坑一:缓存穿透
有个接口查玩家配置信息,有些老玩家的配置 ID 在数据库里已经不存在了(历史数据清理过)。每次请求都打到 MySQL,高并发下直接把 DB 干懵了。
解决方案很经典:缓存空值 + 布隆过滤器。
func GetPlayerConfig(playerID string) (*Config, error) {
// 先查布隆过滤器,如果不存在,直接返回
if !bloomFilter.Exists(playerID) {
return nil, ErrNotFound
}
// 查 Redis
val, err := redisClient.Get(ctx, "config:"+playerID).Result()
if err == redis.Nil {
// 缓存空值,设置较短过期时间,防止缓存穿透
redisClient.Set(ctx, "config:"+playerID, "", 5*time.Minute)
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
// 反序列化
var cfg Config
json.Unmarshal([]byte(val), &cfg)
return &cfg, nil
}
踩坑二:热 key 问题
全服排行榜的 key,每秒几万次读。单个 Redis 节点扛得住读,但万一这个节点挂了……
我们后来用了本地缓存 + Redis 的多级缓存方案。排行榜数据变化频率不高(每秒更新一次),在应用层用 bigcache 做本地缓存,TTL 设 2 秒。这样 99% 的请求根本不会打到 Redis。
// 多级缓存示例
type MultiLevelCache struct {
localCache *bigcache.BigCache
redisClient *redis.Client
}
func (m *MultiLevelCache) Get(key string) ([]byte, error) {
// L1: 本地缓存
if val, err := m.localCache.Get(key); err == nil {
return val, nil
}
// L2: Redis
val, err := m.redisClient.Get(ctx, key).Bytes()
if err != nil {
return nil, err
}
// 回填本地缓存
m.localCache.Set(key, val)
return val, nil
}
3. 数据库优化:该分库分表就得分
MySQL 是我们的最后防线,也是最脆弱的一环。
之前玩家战斗记录表,单表数据量到了 5000w,查询越来越慢。我们做了分库分表,按 playerID 取模分成 64 张表,分布在 4 个库上。
分表之后,查询性能确实上来了,但又引入了新问题:跨表查询。比如运营后台要查某个时间段内所有玩家的战斗记录,这就得扫所有分表。
解决方案是数据同步到 ES。通过 Canal 监听 MySQL binlog,实时同步到 Elasticsearch,运营后台的查询全部走 ES。
MySQL(分库分表) --[Canal]--> Kafka --> ES同步服务 --> Elasticsearch
|
运营后台查询
这里插一句,数据库连接池的配置真的很重要。我们之前用的默认配置,MaxOpenConns 没设,高并发下连接数直接爆了。后来调成:
| 参数 | 调整前 | 调整后 | 说明 |
|---|---|---|---|
| MaxOpenConns | 0(无限制) | 200 | 限制最大连接数 |
| MaxIdleConns | 10 | 100 | 保持足够的空闲连接 |
| ConnMaxLifetime | 0(永久) | 30min | 定期回收老化连接 |
| ConnMaxIdleTime | 0(永久) | 10min | 空闲连接超时回收 |
改完之后,数据库连接相关的报错直接消失了。
4. 限流与降级:保命手段
高并发系统一定要有限流和降级机制。你不能让所有请求都打到后端,该挡的得挡住。
我们在网关层做了令牌桶限流,单 IP 每秒最多 50 次请求,全局限流 5w QPS。超过阈值的请求直接返回"服务器繁忙,请稍后再试"。
// 基于 redis + lua 的滑动窗口限流
var luaScript = redis.NewScript(`
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then
return 0
end
redis.call('ZADD', key, now, now .. math.random())
redis.call('PEXPIRE', key, window)
return 1
`)
降级方面,非核心功能(比如回放系统、邮件推送)在高峰期可以直接降级,优先保障核心玩法(匹配、战斗)的可用性。这个需要和产品经理提前沟通好,不然到时候他突然问你"为什么回放功能没了",你就尴尬了。
5. 异步与削峰:别让同步调用拖死你
前面提到过,我们把很多同步调用改成了异步消息。比如战斗结算后,要更新排行榜、发奖励邮件、记录战斗日志,这些操作完全没必要在同一个请求里同步完成。
// 战斗结算后,发送异步消息
func AfterBattleSettle(result *BattleResult) {
msg := &SettleMessage{
BattleID: result.BattleID,
Winners: result.Winners,
Losers: result.Losers,
Score: result.ScoreChange,
}
// 发到 Kafka,下游服务各自消费
producer.Produce(ctx, "battle-settle", msg)
}
下游的排行榜服务、邮件服务、日志服务各自消费这个消息,独立处理。这样战斗结算接口的响应时间从原来的 200ms 降到了 30ms,吞吐量提升了 5 倍以上。
顺便聊聊最近折腾的一些新技术
虽然工作中用的是稳定方案,但我这人就是闲不住,总喜欢折腾点新东西。最近为了准备面试,也为了拓宽视野,研究了不少 AI 相关的工具。
说个有意思的事。前几天刷 LeetCode 刷到凌晨两点,一道 hard 题怎么都想不出来。然后我打开了 LM Studio,本地跑了一个 7B 的模型,让它帮我分析思路。你别说,虽然它给的代码不一定能直接跑,但思路确实有启发。本地跑模型的好处是免费、隐私安全,而且不用联网,加班的时候断网也能用(别问我为什么加班会断网,说多了都是泪)。
后来我又试了试 Hugging Face 上的一些开源模型,想看看能不能用 AI 来辅助做代码 review。说实话,目前效果一般,对于一些常规的设计模式问题还能给出不错的建议,但涉及到复杂的业务逻辑和性能优化,它就容易"一本正经地胡说八道"了。
还有个叫 OpenClaw 的项目,我最近在关注。它是一个开源的游戏 AI 框架,可以用来训练游戏里的 NPC 行为。虽然跟我们目前的业务关系不大,但我总觉得 AI + 游戏是个很有潜力的方向,提前了解了解没坏处。毕竟跳槽嘛,多掌握点新技术,简历上也好看。
说到简历,我最近一直在琢磨怎么写项目经历。高并发这套东西,写好了是亮点,写不好就成了"吹牛"。我的经验是:用数据说话。别说"优化了系统性能",要说"将核心接口 P99 延迟从 500ms 降到 80ms,QPS 从 2w 提升到 8w"。面试官一看就知道你是真干过的。
一些掏心窝子的话
做了这么多年游戏服务端,最大的感受就是:系统设计没有银弹。
你看了再多架构文章、背了再多八股文,到了实际项目里,还是得根据具体场景做取舍。高可用和高性能往往是对立的,一致性和可用性也得权衡。没有完美的方案,只有最适合当前阶段的方案。
另外,监控和告警真的太重要了。我们之前出过一次线上事故,排行榜数据错乱,排查了两个小时才发现是 Redis 主从切换导致的。后来加了完善的监控,类似问题几分钟就能定位。
最后,身体是革命的本钱。连续加班一个月,我感觉自己老了好几岁。头发掉了一把,颈椎也开始疼。昨天去面试,面试官问我"你对加班怎么看",我笑了笑说"能接受合理的加班",心里想的是"老子加完这阵就跳槽,找个 WLB 的公司"。
好了,编译通过了,我得去跑测试了。希望这次能过,不然今晚又得睡公司了。
如果你也在做游戏服务端,或者对高并发系统设计感兴趣,欢迎交流。毕竟在这条路上,我们都是"代码人生"的赶路人,互相取暖吧。
写于凌晨四点,窗外天快亮了,代码终于跑通了。开心。


评论 0