游戏后台开发
分类: C/C++
2020-01-13 12:04:10
首先功能和性能达标这个就不用说了。下面几条是技术选型过程中优先考虑的点。
在能满足需求(功能、性能、可靠性)的情况下,最大限度依赖开源Redis版本。减少开发复杂度,减少后期维护、升级版本难度。
不对除了Redis本身之外的第三方组件做依赖,做到方便部署、二次开发。集群的管理命令采用Redis的原生格式传输解析,做到使用Redis的SDK就可以管理集群。
对客户端来说就是在使用了一个百万QPS,容量可以扩容到上T的单机Redis,不用关心扩容,不用关心故障。否则用户没有使用云Redis的必要。
GRedis不是作为公有云对外提供服务,只针对游戏的使用场景。解决项目开发、运维过程中遇到的问题。
集群开启了两个端口。一个为监听正常客户端请求的端口。一个为管理端口,集群的管理命令都通过管理端口发送给代理,管理端口只有监控进程使用。
集群架构如上图所示,代理由两种进程组成,监控进程与工作进程(和Nginx进程模型比较类似)。
工作进程是监控进程的子进程,监控进程负责和管理客户端通信,并监控子进程状态。
所有子进程监听同一个端口,对外提供服务,并和所有主Redis通信,如果配置为读写分离版本则所有工作进程都会和其中一个从通信。
后端存储节点由多个单机Redis节点组成,所有的主Redis之间都会相互通信,因为线条太多没有在图中表现出来。Redis集群自我管理心跳,主从切换等逻辑。
Redis兼容原生集群数据路由、管理方案:
(1) 数据分片管理,分成0x3FFF个片
每个Redis只会保存自己对应的分片的数据。同时也会保存所有的分片和Redis的映射关系,也就是每个Redis都保存了一份全局路由信息。
为什么是0x3FFF而不是别的数字:数据被分成0x3FFF个桶和Redis中数据的组织数据的方式有关。0x3FFF正好是省流量方式传播的值的上限,能以比较小的流量传播桶的分布信息。数据被分成0x3FFF份粒度也够细,1T的数据平均每份不到100M,控制得当集群不会产生数据倾斜现象(数据能够均匀分布)。在数据按分片移动的时候也方便控制。
(2) Redis对接收到的数据做校验
Redis接收到请求会判断是应该自己还是其它Redis来处理,如果需要其它Redis来处理,则会返回Moved或ASKED重定向信息。将请求重定向到其它Redis,并忽略此次请求。
在扩缩容,主从切换以及故障转移期间都会利用ASKED和MOVED错误。
(1) 代理根据请求的分片对数据进行路由
每个请求都会根据key来对数据进行分片。
代理中不配置路由信息,只配置了集群中的一个Redis的地址,因为每个Redis中都有全量的路由信息,所以代理每次启动的时候都会去其中一个Redis中拉取全量路由信息。
代理收到请求后根据路由将请求路由到对应的Redis中。
(2) 代理内部消息队列
Redis的协议是没有带回调内容的,请求与响应的消息的映射关系完全依靠顺序的一一对应。所以代理在进行转发的时候需要保证客户端先发送的请求先返回响应。
扩容逻辑对客户端透明。
扩容缩容的逻辑处理方式一样,对代理来说只是路由变了,所以缩容就不单说了。下图为扩容操作步骤。
(1) 集群初始化
首先一个新的集群是不存在扩容问题的,设置好数据分片之后启动代理进程。代理进程启动之后获取到集群的路由信息。
(2) 开始扩容
上面的流程图介绍了扩容的操作流程。扩容为不停机扩容,对客户端透明。下面着重说明在扩容过程中代理和Redis处理客户端请求的逻辑。
以上面流程图中从Redis1中往Redis2中移动分片2举例。在移动过程中一个分片的数据有可能同时存在两个Redis上面。这个时候代理就不确定针对每个KEY的请求应该发往哪里。扩容期间的请求处理主要就是围绕着怎么为请求找到正确的Redis来做的(路由的处理逻辑有一个前提,Redis的请求带的是增量更新数据,触发的更新是增量更新不是全量更新)。
代理是针对的分片保存的路由信息,所以如果一个请求(这里说的请求都是正在扩容中的分片的请求)过来之后代理不知道老的数据有没有被移动新的Redis,所以代理会统一先将请求路由到老的Redis。这个时候就会出现下面四种情况:
A. 数据存在Redis1中,并且没有在移动
和非扩容流程一样,Redis1直接返回请求结果给代理,代理返回给客户端,流程结束。
B. 数据存在Redis1中,正在往Redis2中迁移
这个时候Redis1会阻塞针对此KEY的访问(针对此Redis的请求都被阻塞了)。待迁移操作完成之后走到下面的流程C。
C. 数据不存在Redis1中,已经被移动到Redis2中。
这个时候Redis1会返回ASKED错误,错误信息中包含Redis2的地址。代理转而将请求发往Redis2。这个时候路由信息还没有更新,Redis2也是不认这个请求的,针对这种情况代理会在发送正常请求之前发送一个ASKING消息,Redis2接收到ASKING消息之后会将接下来的一个请求数据认为是自己的。处理完之后Redis2返回结果给代理,流程结束。
D. 数据不存在Redis1中,也不存在Redis2中
处理逻辑同C。
(3) 结束扩容
更新Redis中所有的路由信息。然后更新代理的状态与路由状态。这两个动作之间有个时间差,这个时间之内代理的路由信息是错误的。下面讲解下怎么处理这个时间差之内的路由错误的。
Redis中的路由没有更新,代理中路由没有更新。并且代理中的路由状态为待定,进程状态为正常。代理收到请求之后接着把请求路由到Redis1,Redis1发现不是自己的数据,则返回MOVED错误,错误信息中带有新的Redis2信息,代理将请求重新路由到Redis2,Redis2返回结果给代理,代理将路由状态更新为正常,流程结束。
(4) 总结
Redis会收到属于自己和不属于自己的请求,属于自己的则返回结果,不属于自己的则通过ASKED和MOVED错误帮助代理进行路由重定向找到正确的Redis。
扩容开始后代理会接着把数据路由到老的Redis中,(1)、如果数据没有被移动则直接返回结果,(2)、如果数据已经被移走了则返回ASKED重定向错误,如果数据正在被移动,则阻塞至数据被移动走返回ASKED错误,代理先向新的Redis2发送ASKING标记之后将请求重新发送到Redis2,Redis2返回正确结果,(3)、一个新的数据操作发送到Redis1,Redis1也会返回一个ASKED错误,代理走ASKED流程。(4)、扩容结束之后通过MOVED错误处理一下中间状态,系统扩容结束。
首先对客户端来说集群的代理作为一个整体对外提供服务,只暴露一个端口,并且服务能力能在线扩容。
首先想到的是多线程模型,根据需要起多个线程。一个主线程接收连接请求,然后将连接分发给各个工作线程处理。因为我们代理的任务就是转发请求,然后返回结果。各个工作单元之间不需要频繁的交互信息,多线程之间可以方便的共享信息的优势在这里是不需要的,其次多线程之间的切换也是需要耗费系统资源的。
其次想到的是多进程模型,类似Nginx模式,一个管理进程,多个工作进程,根据负载均衡策略工作进程自己抢任务。多进程的好处有各工作进程之间相互影响比较小,一个进程core其它进程还可以接着服务,进程还可以绑定到CPU固定的核上面,减少上下文切换开销。另外还可以采用exec系统调用的方式平滑升级。
基于系统的健壮性,升级的考虑,代理选择了多进程模型。
提供了多种负载均衡策略供选择
(1) 按连接数负载均衡
首先系统中有个监听锁,哪个进程抢到了监听锁,哪个进程就会监听新的连接。系统会配置一个进程的连接数上限,在达到连接数上限之前每个进程都尝试抢占监听锁,这个地方配置了配置了一个策略,连接数越多,则试图抢锁的时间间隔也越长,也就越不容易抢到锁。
(2) 系统自带的负载均衡策略
通过SO_REUSEPORT选项(内核3.9版本之后支持),多个进程可以同时监听接收客户端连接,不会产生惊群效应,Linux内核使用自带的负载均衡策略分配客户端的连接。这种策略对代理来说是黑盒。下面是内核中分配任务算法的简单描述。
默认情况下可以认为分配任务的方式是HASH(srcip+srcport+dstip+dstport)%监听进程数。基本可以认为是随机分配,分配算法和工作进程的负载、已接收的连接数等完全无关。
在代理进程扩容的情况下,不会因为新启动的工作进程负载低而优先将客户端连接分配给它。
(3) 按客户端类型的负载均衡策略
如果每个客户端对数据库使用的力度差不多,或者QPS很低,不会产生有些进程跑慢,有些进程却很空闲的情况,上面两种负载均衡策略都能满足要求,差别不大。但是面对高QPS请求,客户端之间对数据库使用力度不同的场景,上面两种分配任务策略就不灵了。
针对服务的场景是长连接,连接数和QPS不一定匹配的场景,理论上只有按请求来做任务才比较容易做到真正的负载均衡。但是在高QPS场景下按请求做负载均衡无论采用多进程模型还是多线程模型,都会产生极高的上下文开销。
这里可以先分析下游戏对数据库的使用场景,然后针对游戏的使用场景看看有没有什么比较好的方案。一般游戏中都会分为几种进程,相同功能进程之间使用数据库的强度类似。所以比较好的负载均衡方案是按进程的类型来做。每种进程产生的多个客户端连接平均分配在多个服务进程上,基本可以做到比较好的负载均衡。针对GM等,连接数比较少,然后使用不规律的客户端,可以单独分配一个服务进程。
在标识一个连接的4元组(客户端IP和Port,服务器IP和Port)里面只有IP能和服务类型挂上钩。所以把IP按服务类型归类。然后再把分配策略和归类的IP挂钩。这样负载均衡策略就出来了,分为两种配置
A. 独占
代理中启动若干个进程,专门用来服务非标客户端。
B. 按IP归类的每种类型客户端平均分配给多个工作进程
在实际使用过程中剔除那些使用不规律的客户端,直接按连接数分配任务就可以做到负载均衡。所以可以选择不配置这个IP分类。所以可以使用独立配置加默认路由就可以了,这样可以减少很多运维工作量。
故障分类:网络分区,机器故障,请求阻塞,落地失败
(1) 网络分区
? 单主Redis和集群不通
当主Redis和其它节点不通时,就无法响应其它节点的心跳,最先获取此信息的节点就会发起投票,尝试将此节点下线,当集群中有超过一半的节点同意之后此节点就会被标记为下线。
当从获取到自己同步的主Redis下线之后会发起投票选取一个复制偏移量最大的从,当此节点获得超过一半投票将成为主。
? 单从Redis和集群不通
? 多个Redis之间相互不通(例如11个Redis主被分在两台机器,两台机器之间不通信了)
(2) 机器故障
处理逻辑等同情况1
(3) 请求阻塞
Redis不关心请求阻塞,阻塞之后引起的练手反应由其它机制处理
(4) 落地失败
故障分类:网络分区,机器故障,请求阻塞,进程负载过高,进程down机
(1) 网络故障
? 代理和主不同,尝试重新获取主,获取不到则对请求返回失败处理
? 代理和从不同,尝试重新获取一个从,获取不到则把请求发往主
(2) 机器故障
? 机器故障代理进程无法自己处理,云管平台在新机器重新启动一套代理服务,将IP漂移到新的机器。
(3) 请求阻塞
? 客户端发送过来的请求太多,没有相应的请求积压太多,超过4095个则把客户端断开连接
? 代理和每个Redis之间可以缓存65535个未响应请求,代理和后端Redis请求阻塞则过一个循环之后尝试重新向Redis发送请求。
(4) 进程负载过高
? 达到内存使用上限则停止接收客户端请求
? 针对CPU负载过高,目前只有告警,没有针对这个点做处理(这个时候有可能导致客户端请求积压等连锁反应)
(5) 进程down机
? 工作进程down机则由监控进程重新拉起
? 监控进程down机则由另外的监控系统重新拉起
Redis集群有手动下线机制。
(1) 将从手工下线,将新版本挂到主上面作为
(2) 将主手工下线,新的从作为主,然后再挂一个从到新的主上面
从发送mfstart包给主,通知主,从节点要进行手动切换。主节点会阻塞所有客户端命令的执行。主节点向从节点发送ping包,ping包中包含复制偏移量,当复制完成后开始执行切换流程。阻塞的超时时间默认为5s。从同步完成之后主会将把将被阻塞的所有命令返回MOVED命令,将请求重定向到新的主Redis。
(1) 目前只有采用停服更新一个方法