作者:李世顺,腾讯游戏天美J1工作室游戏后台高级工程师,先后参与过剑灵手游、火影忍者手游、写实赛车项目的研发与维护工作。
火影忍者手游已经上线4年多,活动是火影重要的运营手段,火影的活动除了签到、礼包、任务等商业化运营,最大的特色是有很多“玩法”级别的活动,包括了游戏战斗、sns等元素。如何持续稳定的输出高品质活动成了火影当前最大的挑战之一。
火影当前活动已经400+,而且还将持续稳定扩展,按理说,活动本身逻辑与核心数据逻辑是弱耦的,但是现状却是与gamesvr(火影对应的是zonesvr)耦合在一起,不但影响了核心服务的可用性,也极大限制了活动本身的扩展性。
1. 服务拆分
目前火影大部分需求都是新增的活动,而活动逻辑一直在 gamesvr 中,新增活动严重影响了 gamesvr 的可靠性,拆分出独立的actsvr后,还能提高活动服的扩展性。
2. 状态迁移导致的核心问题
游戏逻辑决定活动一定会产生状态,状态不能放在svr的内存中,要转移到全局的数据库中,从gamesvr中抽出活动逻辑需要解耦,大体包含3个问题:
2.1 数据库:
2.2 数据一致性:
2.3 模块耦合:
1. 为什么是redis
目前项目组使用的 tcaplus 是互娱研制的一款高速分布式的key-value数据库,效率上没有太大问题,但是没有多样化的数据结构、lua脚本等功能,难以应对无状态化编程带来的挑战。
而 redis 作为业界标准,不仅效率高、还支持多样化的数据结构以及lua脚本等功能,公司也有专门团队提供支持,因此活动svr选用了 redis 作为数据库。
tredis 团队除了原生的redis集群支持,还自研了tredis SSD模式,该模式主要特点是用SSD替换了内存,解决了redis 的内存问题,当然也是有代价的。火影活动数据基本都是周期性数据,一般上线一两个星期,下线后数据都可以自动过期清理,不会常驻内存,数据量本身可控,所以采用低延时的 redis cache 方案更合理。
2. Redis 内存
redis 的瓶颈一般都是内存,只要游戏逻辑合理,QPS一般问题不大。游戏逻辑需要的数据经过 pb 压缩后已经没有优化的可能,在数据量固定不变的情况下,提高 redis 的内存利用率,可以极大的压缩 redis 内存。
2.1 redis string 类型内存模型
假定redis使用的内存分配器是jemalloc,dicEntry、SDS、redisObject 等结构都是独立分配的内存块,大小只能是 16、32、64…字节(value不大时redisObject和SDS可能会共用一块内存),即使没用完也会统计到used_memory 指标中。
2.2 redis string 类型内存利用率
实际需求中,大部分 key 的长度在 24-55 字节之间,value 的长度在 8-39字节之间(value一般都会用 pb 等压缩,所以数据不多的情况下长度不会太长)。按照一定的规则规范化 key,可以缩减 key 长度到 23字节以内,每条记录可以缩减 32 字节的内存,value 的内存利用率将提升25%。
2.3 规范化key
实现方法可以用 protobuf 描述层次关系,用反射特性实现名字到id的转换,例如 RedisKey.actsvr.task 被转换成 1|1:
2.4 如何大幅度提高value内存利用率
如果把要使用的 redis 数据都集中到一起,集中存放,则 value 的大小会远大于 key 和其他内存结构的大小,从而使内存利用率达到 50%~99%。然而此方案也有弊端:如果只想取某个子模块的数据也必须把整体数据都拉下来,无状态化的情况下本来就会频繁读写数据,此方案将显著增加 redis 的CPU压力。
redis 的 hash 类型既可以把数据集中存放,也支持 key 分开读写。
2.5 redis hash类型的ziplist内存模型
redis hash 类型在 key 数量少于 512字节(可配置),最大value 成员不超过 64 字节(可配置)时,会采用 ziplist 结构压缩内存占用。
假设数据分为n个模块,每个模块的数据量都不大(8~39字节),两种方案:使用 n 个 string类型分别存储模块数据、使用一个 hash 表n个field存储模块数据的内存利用率对比如下:
模块数越多,hash类型提升内存利用率的效果越明显,主要原因是redis内部存储的辅助数据结构占用空间大大减少,次要原因是 hash 表的 field 对应的 key 缩短了不少,因为只需要标识子模块信息即可。
3. 分布式锁
无状态化后,总有一些需求对数据有强一致性要求,这种情况下,只能用分布式锁:互斥锁虽然能满足大多数需求,但是会影响效率,如果不是必要,可以考虑条件锁,符合条件的情况下即使并行也不会阻塞。
3.1 lua 互斥锁
在 redis 中,同一个 key 可以保证在一台机器上,redis 的单线程执行确保了针对此 key 操作时数据的强一致性。实现分布式锁还需要注意判断加锁解锁的条件、防止死锁等问题,用 lua 脚本实现锁可以很方便的避开以上问题。
a. 上锁
已经上锁了会直接返回失败,上锁的同时还会设置过期时间,防止死锁。
b. 释放锁
lua_str = "if (redis.call('get', KEYS[1]) == ARGV[1]) then return redis.call('del', KEYS[1]) end"
有了对内容的对比,确保只释放自己加的锁,不会误释放其他人加的锁。
3.2 lua 条件锁
条件锁可以减少不必要的阻塞,比如同时加入队伍场景下,可以设置条件锁:仅队伍人数小于5才能加入队伍:
"if (redis.call('zcard', KEYS[1]) < 5) then return redis.call('zadd', KEYS[1], ARGV[1], ARGV[2]) end"
程序框架需要解决模块耦合的问题,还需要提供统一的数据管理功能,包括读写数据、加锁解锁等。
1. 模块切分
以插件化的思路分离各模块,每个模块作为插件自治,可以单独处理客户端发来的拉详情、指定命令、GM 指令。每个活动都可以灵活的 load 指定插件集合,无需关心插件内部实现,只需要实现活动特有逻辑即可。
2. 数据管理
以 hash 类型存储整个活动数据,每个模块占用一个 field,既可以支持整体活动数据的读写,也支持各模块单独读写数据,便于插件模块自治,同时 redis 也能保持很高的内存利用率。
对数据一致性要求很高的模块有对整体或插件模块上锁的需求,从而实现精准控制冲突域。
接口众多,新增需求实现起来必定很复杂,封装一个宏来自动生成代码就显得很有必要了,一个宏可包含以上所有接口及实现:
1. 玩法
五人派对活动是为了增加玩家活跃而设计的组队玩法,玩家可以邀请好友组成最多五人的小队,每个队员只可以翻一张牌,五张牌都不一样,翻牌进度共享,翻牌进度会触发所有队员的任务进度,五个人都翻完牌后所有人都能领取丰厚的任务奖励。
2. 队伍核心操作
2.1 创建队伍
玩家1邀请玩家2、3,玩家2、3同时接受邀请,有可能会创建两个队伍,所以需要加锁
2.2 加入和退出队伍
队伍已存在时,队伍成员是个 set 类型,即使多名玩家同时操作也不会有问题。
2.3 同时加入触发队伍满
用lua条件锁保证后来的成员一定抢不到锁,加入失败。
"if (redis.call('zcard', KEYS[1]) < 5) then return redis.call('zadd', KEYS[1], ARGV[1], ARGV[2]) end"
3. 活动玩法核心操作
翻牌集合做成 set 类型,同时翻不同的牌不会冲突。
用 lua 脚本实现条件锁,仅此牌没被翻才翻牌,此牌已翻翻牌失败:
if (redis.call('zadd', KEYS[1], ARGV[1], ARGV[2]) == 1) then return redis.call('zcard', KEYS[1]) end
1. 开发效率保障
插件化的活动框架极大提高了开发效率,插件模块的高度自治使得新活动开发不再需要关注任务、排行、队伍、分享、兑换等功能。
统一的数据管理方案确保了数据的高效可靠,完备的锁机制为数据一致性问题提供了保障。
2. 服务可用性
将不断扩展的活动从 gamesvr 中解耦,独立成actsvr,不仅增强了核心服务的稳定性,也给了活动服良好的扩展性。把活动数据的状态从 gamesvr 转移到redis中,也就是把gamesvr上的部分风险转移到了redis中。一系列优化措施有效稳定了redis的内存、QPS,确保风险可控。一方面redis作为业界标准,可靠性有保障,另一方面火影使用的redis 集群是由公司专业团队提供的支持,不仅支持在线扩缩容,还有完善的监控告警。
目前火影已经为1000w+用户提供了稳定的游戏活动体验,而redis集群的内存和QPS都在掌控中。