单机100万连接,每秒10万次请求服务端的设计与实现(一) - 前传

因起

大概两年前,半途接手了一个项目,一个Python写的游戏服务器。倒腾倒腾弄上线后,在一台4核16G的服务器上,TPS不满百,平均响应延迟超百毫秒,勉强抗个500-1000人在线。慢的令人发指。业务端反响也不好,没运营多久就下线了,否则要填坑到天明。

当时一个不爽,自己写了个面向游戏的高性能后端框架,计划用一台8核48G的服务器,跑到百万在线,50万活跃,每秒10万次业务请求的性能,一直压箱底,也没机会拿出来用。

今年又接手了个游戏项目,正好有时间拿这个框架试试水,整理框架之余,回忆下之前的历程,便有了这一系列的文章,容我慢慢道来。

目标分解

关于传统的多线程模型,曾经有个古老的C10K问题。简单点说就是10k在线便开始趴窝,这导致了服务端从异步网络IO到后端处理的一系列结构性变革。异步处理模型解决了高代价的线程切换,却依然难以解决事务的原子性,共享资源争用等问题。在现代的多核CPU环境下,连跨线程间及时更新变量都需要专门通知CPU。高负载下跨线程的共享资源管理必然是一个非常复杂的问题,我不希望,在写业务逻辑的时候,会需要依赖一套严格的规范来保证高并发时的正确性,这对团队编码的要求太高,也不利于错误排查。先规避这个问题,把核心业务处理放到个单一线程使用一颗CPU核心来完成。剩余的7颗核心负责网络IO,数据加载,调度,等外围工作。

这样,按照10万次每秒的目标来考量,一次请求可以消耗10000纳秒,按照当前的主流CPU,4G左右的主频,每次业务处理共有40000次时钟周期可用,我估计一半的时钟周期,20000个应该就差不多可以处理一次业务了,计算资源应该是够的。而除了这部分,所有前期的数据准备工作和后期的保存输出,尽量设计成可以多线程高并发低争用完成的模式,以便于多核处理。

根据以往的经验,当数据量或是处理频度大到一定的程度之后,会发生很多奇葩的问题。比方说,某个依赖库报莫名其妙的错误,这种情况下,有时候可以通过自己重新造一个简单但够用的轮子来解决,有时候却不得不去硬啃源代码改现有的库。在系统实现的前期,需要快速的模型实现,验证,测试,重构,开发效率的重要性远高于执行效率,因此,我选择了Java而不是C++来入手编写,后期如果真的要做到极致,再考虑逐步移植到C++上。

与C++ 相比,Java主要有两个方面的问题,一是略低的执行效率,这个本质上关系不大,这不是一个计算密集型的程序,大量的时间应是消耗在内存访问和I/O上,就算Java执行速度是C++的一半,也够用了,而且,不断进化的JIT编译器,工作的还蛮给力的;另一个则是绕不过去的硬伤,GC,垃圾收集是服务器端永远的痛,尤其在高并发大业务量海量内存的时候,来个秒级的Stop The World绝对欲哭无泪。

还是按照每秒10万次请求计算,假设每个业务请求需要用20k内存(已经估计的很小了, 来两个Json串就没了),那么每秒钟,就有20k * 100k = 2G内存需要GC。随便一个minor gc都要上秒。所以,把内存管理全丢给JVM是肯定不靠谱的,必须小心处理。三个方面,一是精心调整JVM关于垃圾收集的相关参数;二是自行维护对象池来管理内存,减少临时对象的生成和回收;三是尽可能避免任何未纳入对象池管理,不可重用的对象,进入老年代(Old Generation),甚至在老年代不断累积。前两点很好理解,第三点,避免一些不可重用对象进入老年区,是个略复杂的问题,后续会在讲具体模块时专门提及。

每秒100k业务的问题想好了,再来想想1M的连接,正常点的连接(不把缓冲区弄到完全不够正常使用)大概需要占用8G左右的内存。再把数据加密,session信息等加上,轻松突破10G,然后,针对TCP堆栈内核还有一系列的管理开销。在游戏服务器可能面临大量小数据包请求响应的情况下(大量请求及响应的实际数据小于40字节),UDP可能会是一个比TCP更合适的选择。

业务并发,连接,好像没啥了? 不,还漏了最重要的数据,每个请求读写n次数据库?读写n次redis?sql语句生成,返回数据的解析。高并发下,就算redis能抗住,Json的序列化,反序列化都是灾难。这些操作还会生成大量的临时对象,又是垃圾收集的灾难。一个巨大的内存数据缓存,是必不可少的。我n年前针对频繁调用的中型配置数据库,写过一个全库引导进内存的OR Mapping,而游戏服务器显然不可能将所有数据都拉到内存里来,这里必然需要一个有较好的缓存维护能力和清晰的模型关系的缓存管理系统。按照之前的系统内存约束,平均每个在线用户,一共也就30k的总体内存可用,如果用户数据复杂到一定的程度,JVM用32G的堆估计不太够,但高于32G之后又会因为指针扩张的问题,带来更进一步的内存需求,这个问题暂时搁置,到时候根据业务情况看,如果内存不够再优化/增加。

主机考量完了,看看网络,假设100k请求中,90%是小于40字节的小请求,10%是平均1500字节的大请求,算上layer 4/3/2/1 的包头,每秒的出站流量大概是 72B * 90k + 1526B * 10k = 6480kB + 15260kB= 21740kB = 173920kb = 173mb 这不是一个太夸张的数字。

资源准备

Java NIO 说了这么多年,肯定是很成熟的东西了。但是异步的数据访问驱动(拟使用Mysql + redis),似乎没有那么好找。网上找了一会,Mysql似乎只有看上去有些坑还没填上的mysql-async 和Mysql 8 的X DevAPI,Redis的异步驱动倒是很多,但我不太了解,没有什么选择思路。找了一会之后发现Vert.x 正好有完整的对网络IO,Mysql,Redis的异步访问封装,那就先用Vert.x吧,这三个模块在都是准备抽象出来只放在框架中使用的,未来替换成本及低(结果没多久就真的要换了-_-! )。

框架思路

异步模型的思路从来都很简单,只是很多时候代码阅读起来会有点复杂。对于大部分服务器端程序来说,每个请求的主线基本都是: (1)读取网络数据 ->(2) 按需读取持久化数据 ->(3) 业务处理及保存数据 -> (4)网络数据输出,这里的(1)(2)(3)(4),前期拟各用一个/或几个线程来完成,所有的 -> 通过队列通讯。其中,第(2)步牵涉到和数据缓存的一些交互,第(3)步结束后有一些数据保存的需求。图我先不画了,以后有空再补上,

思路有了,先开工吧,边写边想边细化。

第一个坑

搭环境、建工程和一些基础代码就不废话了,先写(1),读取网络数据包并预处理。收到数据包后应该干什么? ——基本的session和用户信息维护。那么,应该用什么数据结构维护session? 我的第一反应是HashMap(后来改了,以后慢慢说),key - object映射,灵活,快捷,高性能。

不管什么语言,基本都有现成的HashMap库可以调用,Java里比较典型的是自带的HashMap和线程安全的ConcurrentHashMap,信手拈来。

可感觉那里有点不对劲,HashMap和ConcurrentHashMap貌似都和前面我提到的高性能服务器垃圾收集三大套路中的两个相违背。以下便是这两个Map的原罪:
1 创建了大量的,无用对象。Java里的HashMap泛型是<Object, Object>,除非key使用String(对象缓存),否则会需要新建一个Key对象来做get和put,这个对象会被保存在HashMap的节点中。Java并没有提供类似于 int/long - Object 这样的,用基本数据类型做主键的key-value HashMap。而如果用String做key,内存占用增加,性能下降(需要查找一次String对象),也会对通讯部分有性能(转换成数值)或带宽(String传输字节数较多)资源占用的影响。
2 HashMap内部用了HashEntry/Node来包装Key和Value,每次put新key,都会新建一个Entry/Node,如果一个HashMap中有100k数量级的对象, 也会有100k个对应的Entry/Node对象。而如果某个key - value对长时间保存在HashMap中之后才被移出,其对应的Key和Entry/Node都应该已被移入老年代,成为非FullGC难以清除的对象。增大FullGC的概率。

重复写轮子已是迫不得已,写基础类库更是吃力不讨好。从可靠性到性能,很多基础类库都是大神们浸淫数年的精华所在。但该填的坑总得填,从HashMap开始吧,没想到,本系列文章的第一篇正文,将会是,写一个高性能,低内存,线程安全,GC友好的HashMap

本文所涉及的部分代码,会随着文章进度逐步整理并放到 github上。
其中,高性能基础数据结构的代码见 https://github.com/Lofint/tachyon

发布了3 篇原创文章 · 获赞 0 · 访问量 1806
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 大白 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览