
该服务是 QQ 游戏生态中的核心基础设施之一,具有极高的并发写特性,直接关系到用户成就的实时展示与游戏权益的即时到账。
重构前规模: 部署 XX台 服务器(4核8G),在热门游戏活动期间仍面临巨大的资源压力和数据一致性挑战。
重构前,系统是基于 C++ 的同步直写架构。这就好比你去银行存钱,柜员(服务器线程)必须当着你的面(同步),跑去金库(CMEM),确认金额,存进去,然后再跑回来告诉你“好了”。 如果是平时还好,但到了腾讯旗下国民级体量的游戏搞活动时,成千上万个玩家同时挤在柜台前,甚至同一个玩家的多个行为并发上报(比如同时触发“登录”和“等级提升”),面临的压力就非常大。
C++ 老代码在处理这种并发时,采用的是乐观锁 CAS(Compare-And-Swap)机制。
痛点很明显:
CAchieveSetImpl::ProcessData 中,多个线程同时抢占同一个用户的版本号,谁手快谁赢,手慢的只能报错重试或者丢弃。2.1 痛点:同步直写的“大乱斗”
在 C++ 旧版本中,我们的处理模型是典型的同步阻塞: Request→ProcessData→ Get(CAS Ver)→Calc→Commit(CAS Ver)。这就像 10 个线程同时去抢这一个数据版本号,只要有一个线程 Commit 成功,版本号变更,其余 9 个线程的 Commit 就会因为版本不一致而失败,导致大量的 CPU 浪费在无效计算和重试上。

2.2 破局:Kafka 分区的“单行道”
重构的核心在于引入 Kafka 并利用其 Partition 机制。我们将用户的 UIN 作为 Partition Key,确保同一个用户的请求永远落在同一个 Partition。 而后端的 Go 服务作为 Consumer,单协程顺序读取 Partition 数据。在物理层面,我们将“并发写”变成了“串行写”。

架构决定上限,但代码细节决定下限。让我们把镜头拉近,看看 C++ 和 Go 在处理核心逻辑时的“降维打击”。
3.1 消失的 CAS 噩梦
虽然新架构依然保留了 CAS 机制作为兜底防线,但两者的生存环境天差地别。
// Go 版本的逻辑:清晰、自信
func (a *Achieve) ProcessAchieveData(...) error {
// 1. 获取数据 (因为串行,这里的 CAS 版本号极大概率是最新的)
cas, memAchieveInfo, err := a.GetAchieveData(ctx, cmemKey)
// 2. 内存计算 (更新逻辑与 C++ 保持一致,但没有了锁的焦虑)
// ...
// 3. 落库 (这一步几乎不会因为 CAS 失败)
if changed {
a.Proxy.Set(ctx, cmemKey, memAchieveInfo, ..., WithSetCas(cas))
}
}3.2 复杂用户数据的分裂
当用户成就数据过大时,我们需要将其拆分存储到子 Key 中。这部分逻辑的重构,充分体现了 Go 在数据结构操作上的便捷性。
// C++ 代码片段:为了排序不得不折腾一遍 multimap
multimap<long long, SGCAchievePlayer> mapAllPlayerInfo;
for (; it != mapAllData.end(); ++it) {
mapAllPlayerInfo.insert(pair<long long, SGCAchievePlayer>(stAchieveData.lLastModifyTime, it->first));
}
// 然后反向迭代器遍历删除...这种写法不仅内存开销大(这就解释了为什么旧服务需要 8G 内存),而且迭代器失效(Iterator Invalidation)的风险极高。
// Go 代码片段:原生切片排序,清爽自然
sort.Slice(playerDataList, func(i, j int) bool {
return playerDataList[i].data.LLastModifyTime > playerDataList[j].data.LLastModifyTime
})
// 直接切片操作,无需额外的复杂容器逻辑更直观,内存抖动更小,代码量减少了近 40%。
3.3 数据合并策略优化
通过按角色(player)维度对上报消息分组,并按时间戳排序,确保每个type类型只保留最新数据。这种合并策略将原本可能产生的多次CAS竞争减少为单次操作。
// 按角色分组 + 时间戳排序 + 智能合并
type itemWithTS struct {
item achieve.SAchieveReportItem
ts int64 // Kafka消息的时间戳
}
func (a *AchievesLogic) ReportAchieveData(ctx context.Context, aMsgs []*model.AchieveMsg) {
playerItems := map[common.SGCAchievePlayer][]*itemWithTS{}
// 1. 按角色(平台+区+服+角色ID)分组
for _, aMsg := range aMsgs {
player, items := parseReportData(aMsg)
playerItems[player] = append(playerItems[player], items...)
}
// 2. 每个角色的数据按时间戳倒序排列(新的在前)
for _, items := range playerItems {
sort.Slice(items, func(i, j int) bool {
return items[i].ts > items[j].ts
})
}
// 3. 去重:同一个type只保留最新的
for player, items := range playerItems {
existType := map[int32]struct{}{}
typesTS := map[int32]int64{} // 记录每个type的最新时间戳
mergedVecData := []achieve.SAchieveReportItem{}
for _, item := range items {
if _, ok := existType[item.item.IType]; ok {
continue // 已经有更新的了,跳过
}
typesTS[item.item.IType] = item.ts
existType[item.item.IType] = struct{}{}
mergedVecData = append(mergedVecData, item.item)
}
// 4. 合并后的数据
reportData := aMsgs[0].Data
reportData.VecData = mergedVecData
err := a.Store.ProcessAchieveData(ctx, reportData, typesTS)
}
}这次重构不仅是语言的迁移,更是对系统吞吐模型的重塑。
1、资源成本骤降 82%
2、稳定性指标跃升
指标 | 重构前 | 重构后 | 优化幅度 |
|---|---|---|---|
服务器数量 | xxx台 | xx台 | 减少82% |
CPU核数 | xxx核 | xxx核 | 减少73% |
内存规格 | xxxxG | xxxG | 减少91% |
综合成本 | - | - | 减少82% |
3、告警与监控能力提升
4、掌控核心业务数据大盘,实现精细化运营
除了底层的系统指标,我还主导建设并完善了整个成就系统的业务大盘监控。通过对 Kafka 队列中流转的成就数据进行实时聚合和可视化,我们得以:

从 C++ 到 Go,从同步直写到异步队列,这次重构本质上是一次“用架构空间换取计算时间”的胜利。
我们不再让 CPU 在无休止的锁竞争和 IO 等待中空转,而是让每一行代码都运行在有效的业务逻辑上。这些服务器的轻装上阵,不仅承载了现有的业务压力,更为未来更高并发的游戏活动留足了想象空间。 对于后端工程师而言,最爽的时刻莫过于:看着监控大盘的流量波澜不惊,而服务器列表却缩减了一整页。
-End-
原创作者|陈颀玮