本文介绍一种游戏服务端利用双缓冲队列数据落地的技术,这种技术既能保证高响应速度又能降低数据丢失的风险。
首先介绍了游戏服务端数据模型和数据生命周期,然后介绍双缓冲队列的具体设计。
数据模型
玩家数据的落地存储使用的是MySQL,但不适合用关系模型来构建玩家数据。 因为玩家数据是一种树形结构,关系模型是一种二维结构。
Player = {
id = 123456,
name = "Winston",
level = 1,
gold = 2000,
gem = 300,
vip = 4,
items = {
{id=101, count=20},
{id=102, count=30},
},
heroes = {
{id=1001, level=1},
{id=1002, level=2},
},
quests = {
{id=10001, condition={type=1, number=10}},
{id=10002, condition={type=2, number=20}},
},
...
}
以上代码是我们游戏服务端玩家数据的部分片段。 可以看出id,name之类的字段对应MySQL中的一列还说的过去,但像items,heroes这类复合数据结构如果非要用关系模型建模
的话可能需要几十张表。
我们用一张MySQL表来存储玩家数据,表定义如下:
CREATE TABLE players (
id INT NOT NULL,
name VARCHAR(64) NOT NULL,
level INT NOT NULL,
gold BIGINT NOT NULL,
gem BIGINT NOT NULL,
vip INT NOT NULL,
items TEXT NOT NULL,
heroes TEXT NOT NULL,
quests TEXT NOT NULL,
...
PRIMARY KEY (id)
);
Player树形结构中的第一层对应MySQL的一列,基本数据类型可以跟MySQL列数据类型一一对应,复合类型用TEXT或BLOB存储。
这样设计的好处是: 1)方便运营层面的查询统计 2)不用拆成多张表
坏处是无法对复合结构中的单个字段操作,必须整体读取和写入。 不过一般不是大问题,因为游戏服务端的应用场景都是对这些数据整体操作。
数据生存周期
玩家数据存储在MySQL中,主要用以下几种阶段:
- 玩家未登录时,数据仅在MySQL中。
- 玩家请求登录时,数据从MySQL中加载到游戏服内存中。
- 在登录回应中数据发送到游戏客户端并存在于客户端内存中。
- 服务端和客户端读取玩家数据时都只会从自己的内存中去读取。
- 修改数据一般由客户端发起,服务端修改自己内存,然后回应给客户端,此时客户端修改自己内存。
- 玩家下线时,服务端把自己内存中的数据保持进MySQL,至此数据仅在MySQL中。
可以看出,无论是游戏服务端还是客户端,大部分时间都是在读写内存。 所以玩家的各种操作都很快,不会出现Web应用中那样大部分操作都要等待数秒。
但这种设计有个缺陷,如果游戏服务进程crash,则会丢失部分数据。 所以游戏服务器并不是只有在玩家下线时才保存数据进MySQL,一般会定时或者某个特定玩家操作是也存一次MySQL。
本文探讨一种针对降低数据丢失风险的数据落地方案。
DataServer的出现
为了避免游戏服务进程crash,我们的业务逻辑大部分用Lua编写,Lua虚拟机是一个相对安全的沙箱环境,即使代码有BUG也很难crash。 这套机制在线上运行五年来从未crash过,但近期不知道引入 了什么代码,开始出现crash现象。 堆栈显示coredump的代码在Lua虚拟机free内存时,抛开具体原因不谈,我觉得即使用了Lua也无法保证进程不crash。 主要原因是我们的游戏服务端逻辑非 常复杂,代码量非常大,很难保证不出BUG。 所以新项目我决定把在线玩家数据放到一个独立的进程中去,我们姑且叫它DataServer,然后GameServer就变成无状态了,再也不怕进程crash了。
DataServer和GameServer部署在同一台物理机上,它们之间通讯因为是走localhost,所以不经过协议栈,也没有网络延迟,仅有序列化和内存拷贝的开销,因此我要求DataServer提 供10ms内响应时间。 另外我要求DataServer的代码量在1000行以内,代码简单才有可能保证不crash。 DataServer用Go语言实现,因为它简洁优美,专为服务端设计。
GameServer接收到玩家数据修改请求后会调用DataServer的API,DataServer返回成功效果即视为修改成功。 DataServer收到的API请求一般为:
SET 123456 level 10
SET 123456 vip 2
SET 123456 gem 100
SET 123456 gem 200
SET 123456 gem 180
SET 123456 gold 2000
SET 123456 gold 1500
SET是DataServer提供的API,第二个参数是玩家ID,后面参数是Key-Value,可以有一对或多对。 用过Redis的读者能看出这就是Redis的命令,事实上我们DataServer提供的API就是用的Redis的协议。
DataServer收到请求后会:1)保存在自己内存中的数据结构中 2)返回给GameServer成功回应。 这是一种Fire-And-Forget的模式,所以DataServer的API响应时间很容易保证在10ms以内。
剩下的问题是DataServer用何种方式保存内存中的数据进MySQL?
双缓冲落地队列
DataServer收到KV数据后会插入到一个队列中,然后会有专门的goroutine从队列中取出KV,最后保存进MySQL。 为了能够并行我们可以这一设计:
- 启动时创建N个Queue,同时创建N个goroutine(我们叫它Saver),一个Saver对应一个Queue
- 收到KV后根据
hash(key) % N找到一个Queue,因为N是固定不变的,所以这一步无需加锁 - Saver会去检测自己Queue是否为空,是则走储存进MySQL流程,否则休眠一段时间
- Saver还要做合并KV工作,因为队列中可能有多个相同的SET
type DataServer struct {
queues []*queue
}
func (s *DataServer) Start(n int) {
s.queues = make([]*queue, n)
for _, q := range s.queues {
go s.saver(q)
}
}
func (s *DataServer) Set(key string, kvs [][]byte) {
h := hash(key)
q := s.queues[h % len(s.queues)]
q.insert(key, kvs)
}
func (s *DataServer) saver(q *queue) {
...
}
type queue struct {
nodes *node
}
type node struct {
key string
kvs [][]byte
next *node
}
此时我们还没看到任何加锁行为,感觉很不错,那么q.insert里面呢? 情况有点复杂,saver会出队queue,insert会入队queue,那就用sync.Mutex加下锁吧。
我们在做客户端开发时,会知道可以通过多线程渲染来提交性能,简单说就是CPU线程并不直接调用图形API,而是提交抽象的渲染命令给一个队列,然后由一个独立的线程去读取渲染命令队列然后再去调用图像API。
那我们在DataServer里遇到的场景跟多线程渲染很像,Set会高速的插入队列,saver会取出队列合并KV最后提交给MySQL。 所以我们决定把queue设计成前后两个队列,同时我们还想做点Fancy的事情,把
queue设计成无锁队列。
type queue struct {
front unsafe.Pointer
back unsafe.Pointer
}
func (q *queue) insert(key string, kvs [][]byte) {
n := &node{key: key, kvs: kvs}
for {
top := unsafe.LoadPointer(&s.front)
n.next = top
if atomic.CompareAndSwapPointer(&s.front, top, unsafe.Pointer(n)) {
return
}
}
}
queue内部有front和back两个队列,insert会插入front,并且我们用原子操作无锁的插入队列。 再来看下saver,它需要遍历queue来合并KV。 我们让saver每次循环都先去检查back,如果
不会空,则合并KV并写入MySQL。 back只会被saver访问,所以无需加锁。 如果back为空,那就交换下front和back的指针,跟客户端渲染流程有点像呢。
func (q *queue) swap() {
if q.back == nil {
q.back = atomic.SwapPointer(&q.front, nil, q.back)
}
}
func (s *DataServer) save(q *queue) {
for {
q.swap()
if q.back != nil {
// Merge KV and save to MySQL
for n := (*node)(q.back); n != nil; n = (*node)(n.next) {
}
}
}
}
知识的融会贯通的是多么有趣啊!
总结
有了DataServer作为数据落地的中间层,GameServer可以安全的“崩溃”了。 数据在DataServer中接近实时的存进数据库,即保证了GameServer对客户端快速响应,又增强的了数据的持久性。
但还有一个问题,数据虽然只在DataServer短暂停留,但如果DataServer崩溃了,还是可能会丢失几百毫秒的数据。 虽然DataServer只有不到1000行代码,通过code review可以消除crash 的隐患,但经历了线上数据的丢失,我们还是不够放心。 所以未来我们会让DataServer先把KV写入到一个WAL日志中,KV是实时落地的(其实也不是)进一步降低丢失风险。