« 开发笔记 (1) | 返回首页 | 游戏数值策划 »

开发笔记 (2) :redis 数据库结构设计

接上回,按照我们一期项目的需求,昨天我简单设计了数据库里的数据格式。数据库采用的是 Redis ,我把它看成一个远端的数据结构保存设备。它提供基本的 Key-Value 储存功能,没有层级表。如果需要两层结构,可以在 Value 里保存一组 Hashes 。

这是我第一次实战使用 Redis ,没有什么经验。不过类似的设施几年前自己实现过,区别不大。经过这几年,有了 Redis 这个开源项目,就不需要重造轮子了。但其模式还是比较熟悉的。也就是说,是按我历史经验来使用 Redis 。

一期项目需要比较简单,不打算把数据拆分到不同的数据服务器上。但为日后的拆分需求做好设计准备。以后有需要,可以按 Key 的前缀把数据分到不同的位置。例如,account 信息是最可能独立出去的,因为它和具体游戏无关。

用户系统使用 email 来做用户名,但在数据库中的唯一标识是一个 uid 。用户应该允许修改登陆名(用户很可能更换 email)。用户的身份识别是用 id 来定位的。所以,在数据库中就应该有如下几组 Key :

  • account:count id
  • account:userlist set(id)
  • account:email:[email] id

这里,account:userlist 对应的 value 是一个 set ,里面存放了所有存在的 user id 。用于遍历所有的 user 。这个暂时可能用不上,而且当用户量相当大的时候可能有问题。不过暂时不用考虑这么多问题,等以后改进。

account:count 是一个计数器,可以用来生成唯一 id 。

account:email:[email] 用来标示每个注册的 account 的登陆名。[email] 指登陆用 email 地址。

这里,email 内可能也存在符号 ":" ,为了回避这个问题,许多对 email 进行编码。我的方案是,将字母数字 @ . _ 之外的字符编码为 %XX 的形式。用 lua 干这件事情非常简单:

local function _encode(str)
    return string.format("%%%02X",string.byte(str))
end

function emailEncode(str)
    return string.gsub(str,"([^%w_@.])",_encode)
end

当然,解码回来也很简单

local function _decode(str)
    return string.char(tonumber(str,16))
end

function emailDecode(str)
    return string.gsub(str,"%%(%w%w)",_decode)
end

之后,就是 account 下每个 id 的数据:

  • account:[id]:version number
  • account:[id]:email string
  • account:[id]:password string // md5(password..salt)
  • account:[id]:nickname string
  • account:[id]:lastlogin hashes
    • ip string
    • time string
  • account:[id]:history list(string)
  • account:[id]:available enum(open/locked/delete)

其中,密码不想保存为明文。因为任何可能的数据泄露都会导致用户的损失,我也不想任何人看到用户的密码。所以采用 md5(password .. salt) 的风格。

md5 运算前,加一个 salt 后缀,是因为单纯的文本 md5 值也是有数据库可查的。

lastlogin 下保存了用户最后一次登陆的信息,使用了一张 hashes 表,因为这些信息在未来会进一步扩充。

history 保存了用户登陆的所有历史记录,用一个 string 链表记录。

用户删除自己的账户时,不想把数据从数据库删除,只想在 available 下做一个标记。

考虑到数据库内数据结构有可能发生变化,所以加了 version 域做版本标识。


我不想让各种服务可以直接读写这份数据,所以,会单独写一个认证服务器做处理。

认证服务器提供三项服务:

  1. 用户注册

  2. 用户名 密码 认证 (用于 ssl 连接上的 web 服务)

  3. 用户名 密码 挑战式认证 (用于 client 的认证服务)


下面是基本的场景服务用的数据:

  • account:[id]:avatars set(id)
  • avatar:count id
  • avatar:[id]:version number
  • avatar:[id]:account id
  • avatar:[id]:scene string
  • avatar:[id]:available enum(open/delete)
  • avatar:[id]:data hashes
    • name string
    • figure string
  • world:scene hashes
    • [name] id
  • scene:count id
  • scene:[id]:name string
  • scene:[id]:available enum(open/close/delete)
  • scene:[id]:info hashes
    • time string
    • pc number
  • scene:[id]:pc hashes
    • [id] enum[online/offline]
  • scene:[id]:pc:[id] hashes
    • status string

用户账号下可以有许多游戏角色,列表放在 account:[id]:avatars 下。

每个角色也拥有一个唯一 id 。这个 id 原则上和 account id 是独立体系,但是为了人类好区格,avatar:count 的起点和 account:count 不同。

角色所在场景记录一个字符串的场景名 avatar:[id]:scene ,角色的其它各种数据放在一个 hashes 里。

所有的场景索引方在 world:scene 下。如果日后有多个世界,可以采用 world:[id]:scene 。但目前不必考虑。

scene 下面的所有 pc 的在线状态放在 scene:[id]:pc hashes 中,pc 离线也把它的 id 记录在内,只有 pc 转移场景才移除。

每个 PC 的位置状态信息记录在 scene:[id]:pc:[id] 中,第一个 id 是 scene 的 id ,第二个则是 PC 的 avatar id 。

btw. 这是一份草稿,虽然思考不周,但足够满足项目一期的需求。当然许多欠考虑的地方也并非是考虑不到,而是希望尽量简单,以满足一期需求为目的。这个日后修改的代价并不大。


最后吐槽一下 Redis 的 Windows 版。办公室的 Linux 服务器还没有装好,我暂时在 Windows 下做开发。取了一份 google 搜到的 非官方 Redis 的 Windows 版 。为了图方便,使用的是 luajit ffi 去调用 hiredis 的 dll 。一开始怎么都搞不定。建立不了 socket 连接,出错码也取不到。

对比了源代码,发现修改版把 C Struct 结构改了,前面增加了几个域,而我以 hiredis 官方标准来定义的接口。

改好后,能够正确取出出错码了。发现万恶的 Windows socks api 需要调用 WSAStartup 才可以用。而 hiredis 的 Windows 修改版居然没有去调用。让我大费周折才改好,前后折腾了一个多小时。

再吐槽一下 hiredis 的 API 设计,居然依赖 C Struct 的布局。良好的 C 库的接口设计不会这么干的吧。比如 lua ,又比如 zmq 。唉,用这种东西有点小不爽。不过比 C++ 库还是好太多了。

Comments

没有人发现这个设计中有个坑吗?account:count id,在同一时间如果多于两个用户注册的话,可能会导致id冲突

@flysnowxf

游戏中的数据结构很简单,但对查询效率的要求很高,又没什么复杂的关系模型,自然就不需要使用关系型数据库了。复杂的查询可以自己建索引,说不客气的,关系型数据库里用到的那些范式的效率实在是不敢恭维,顶多拿它存点运营需要的数据。

account:email:[email] id 用来标示每个注册的 account 的登陆名。请问一下 “标示”的意思是收集所有用户的登录名,还是使用用户id标示用户的登陆名,如果是这样,又有什么用处呢

第一次留言,:)
我记得是前天看到的这个。
然后花了点时间去琢磨redis,感觉效率异常的高效。
我现在是这样。
数据落地的时候, 先根据mysql的结构入一份 mysql 一份序列化到 redis 。create的时候, 先去redis找,如果找不到。直接去 mysql读。
把所有数据反序列化出来 也就<5ms 而已。
而之前 去mysql 读取各种 邮件, 道具, 好友。。。基本要> 50 ms

对游戏中的数据不太了解,说说我在web开发中对redis的使用经验:
redis比较适合于需要实时更新或排序的数据,因此我们的用户数据是存mysql的,同时有部分用户实时数据同时也存在redis
redis的持久化和主从复制目前都不是很可靠,因此用来作为主服务器存用户数据似乎太激进了


ps:我们的系统里md5+salt里的salt是每个用户单独生成的,因此相同密码也不会有相同的md5结果

有个疑问,场景中存储的貌似都是短暂有效的数据,进入其他场景后还有保存的必要吗?HP/MP/BUFF 均会发生变化

为什么不把玩家的账号资料与游戏数据分开存放?

对redis memcached 不太熟悉,只知道是内存数据库,我的疑问是数据总归要存盘的,需要mysql之类的数据库做存盘吗

@Liu li

@Cloud

我后来的做法是一开始就启动几百个redis进程在一台服务器上,然后直接根据key的hash值来distribute,要迁移的某个进程的时候先设置目标服务器上的相同进程为slave,然后redis就会自动同步数据,同步完毕之后redis有一条命令可以把slave升级成master,升级完毕后关掉原来的实例。因为redis的进程非常轻,所以这样做也是可以的,只是缺点是不能用keys命令,而且原来monit只需要监控一个redis进程,现在需要监控几百个redis进程。

“redis有一条命令可以把slave升级成master。”-----这句话是相当的扯蛋,因为redis就没有这样的命令,难道你没用过redis?

Redis似乎每次启动都会将所以数据载入到内存中,使用REDIS做主存储的话,是否可能会载入很多不活跃用户的数据而造成启动的时候的效率浪费?

既然用Redis做数据库也用Erlang,直接用Erlang上面的Riak吧,Riak的性能、数据备份、可扩展性非常之好!Redis不足的地方或者要自己造轮的地方它都有现成的实现了。

再吐槽一下 hiredis 的 API 设计,居然依赖 C Struct 的布局

Intel那些讲代码优化的,天天讲的就是这个,如何控制内存布局。最重要的是,我好多地方也依赖于C Struct的布局来所谓的提高效率----你这么一说,我觉得被你抽脸一样

我曾经想做一个慢及时战略游戏-黄巾之乱,就是把三国志之类的游戏加上网络,变成多人游戏,一个玩家可以控制多个单位,而每个单位在游戏世界中都是一个实体对象。我用mssql做M,存储过程做服务器控制,前端用as3写flash。
最后失败了,我发现,我加上目前的WEB游戏开发工具,无法完成这个大世界。唉~~~

还有一点建议,使用key-values系统的时候,要注意一下存入数据序列化的问题

facebook修改过一次序列化的方法,节省了非常大量的存储空间

因为我们是用mc协议,序列化用的是igbinary

对redis了解得不多,但一直在用membase,因为是采用MC协议,简单方便

但同为key-values系统,随便讲几句
关于唯一id,我随便set了一个值,然后对这个值采用increment方法,返回结果就是自增的唯一值;increment是原子操作,效率挺高

看到有朋友留言说redis依赖内存的大小,membase的集群很强大,可以共享多台server的内存

读完之后,才发现最初我数据库字段的命名是多么的丑陋,这个和英文功底有关吗?还是和代码阅读量的大小有关

看了你写的这些东西,大体明白,你想做一款SNS的PVP mmorpg。

用过redis后,都不习惯用mysql了!

我天生就不是学这个的材料, 看着有点头痛。

之前有印象看过大话西游/梦幻西游居然是不用数据库的,现在居然只用redis。我到现在一直理解不了,只用key-value就能满足复杂的查询?

云风有用到redis的持久化么?如果没有的话,你们是怎么做持久化的

不知道到时候会不会支持mac版终端

云风,redis的代码,你觉得写的怎样。我看了几个文件,感觉代码现质量一般。另外,能够后面能够分享一下redis的性能、可靠性方面的内容,谢谢!

有点疑问,为什么不用传统数据库呢?还是多服务器设计的方便呢?还是性能上考虑?

那啥,为啥用email作为account呢?难道要用一个庞大的SNS和game产生共鸣?
我在看计算机语言书籍的时候曾经想,除了编码语言,C语言可能使用范围最广~因为好多国家好多人都会涉及这门语言的呦~#.#

使用Redis使用作为主要数据库,太过激进了。
mysql的性能和周边丰富的支持工具,还是很给力的。

Redis对现在的开发人员,对以后的运营数据分析人员。都是一个可怕的存在。游戏数据可不是评论,文章之类的文本,是玩家的财富,稍有不慎,都是可怕的错误。

Redis作前端缓存,mysql作台端存储还是不错的。

要我说良好的库设计应该像是 java/c#这样的

你确实不时吐槽一下c++,让广大c++用户不爽

全使用kv难以应付复杂的关系数据,特别在redis只有一级hash的情况下,
推荐redis和mysql结合使用.

减少内存占用和提高性能都属于优化。

适当用一下hash可以减少内存占用和提高性能

这样做整个数据是扁平结构,如果用hset做二层结构不是更清晰么?你出于什么考虑不选择这个方式呢?

用 md5 就是看中它快吧.

只是扰乱一下明文, 主要是不至于让维护人员无意中看到用户的密码而已.

当用户量大了,增加计算密文的成本不就是给自己增加成本么。

另,现在的共识应该是用bcrypt来加密密码而不是md5+salt了吧,主要原因是md5太快,所以就算自己生成表在GPU上跑也不是太了不起的事。

@Cloud

我后来的做法是一开始就启动几百个redis进程在一台服务器上,然后直接根据key的hash值来distribute,要迁移的某个进程的时候先设置目标服务器上的相同进程为slave,然后redis就会自动同步数据,同步完毕之后redis有一条命令可以把slave升级成master,升级完毕后关掉原来的实例。因为redis的进程非常轻,所以这样做也是可以的,只是缺点是不能用keys命令,而且原来monit只需要监控一个redis进程,现在需要监控几百个redis进程。

redis 有数据库大小不能超过物理内存的限制,把 redis 作为主数据库是不是稍微激进了点?

用redis最好用hashes而不是strings,能节省大量空间

@Liu Liu

数据迁移的问题,我的设想是这样的.

让服务优先读取新服务器,如果取不到 key 就去老服务器读.并不再写入老服务器.

另外开一个任务从老服务器读出所有数据写到新服务器.这个任务完成时,就是迁移完毕点,可以关闭老数据服务.

@无限

你没仔细看.

为什么不用MongoDB呢?难道是偏心Redis是纯C写的? :)

%xx 形式key的长度扩大了2倍 1:3

不如用 base64 这样只扩大了1/3 3:4

而且 base64 可以修改下 映射的 charsets , 这样万一数据泄漏出去 别人也很难dump出原始的信息

@Liu Liu

Windows 版只是现在临时弄弄.

@Kommit

加上 account id 是好一些. 不过不够灵活. 不知道日后会不会改变 id .

觉得antirez根本就没关心过windows的设计。另外插一句,redis需要所有数据都在内存里面,如果全部数据都靠redis还是比较危险。我们现在有16G还要多的数据存在redis里面还是一台服务器,现在无论是迁移还是升级都特别麻烦(而且是开了redis-vm的,现在都被deprecated掉了)。插一句把redis和jemalloc一起编译非常好,之前redis有很严重的内存碎片问题,用了jemalloc就没事了。

和mongoDB是一类的吧,真的像传说的那个高效吗?

md5(password .. salt) 这样对于同样的密码会生成同样的字符串,我觉得 md5(password .. salt .. account_id) 会更安全一些。

对数据库设计过程讲解很细致啊,如果找工作前看到这篇文章就好了……

Post a comment

非这个主题相关的留言请到:留言本