ofo长连接锁服务优化实践

1

背景

ofo有多个业务涉及到长连接,其中智能锁和锁服务通信是使用长连接的一个重要的应用场景,本文先从C10K问题入手,逐步介绍长连接锁服务和优化的过程。

2

C10K问题

早期的互联网功能简单,用户也不多,客户端和服务器端不需要太多的交互。随着互联网的普及和发展,应用程序的逻辑也变的更复杂,从简单的表单提交,到即时通信和在线实时互动,C10K的问题才体现出来了,每一个用户都必须与服务器保持TCP连接才能进行实时的数据交互,一些大型的网站同一时间的并发TCP连接可能会过亿。

最初的服务器都是基于进程/线程模型的,新到来一个TCP连接,就需要分配1个进程(或者线程),而进程又是操作系统最昂贵的资源,一台机器无法创建很多进程。如果是C10K就要创建1万个进程,那么操作系统是无法承受的。如果采用分布式系统,维持1亿用户在线也需要10万台服务器,成本巨大,这就是C10K问题的本质。

3

C10K问题的解决方案

为了解决C10K问题,主要思路有两个:一个思路是对于每个连接处理分配一个独立的进程/线程,但是这种资源占用过多,可扩展性差,不可行;另一个思路是用同一进程/线程来同时处理若干个连接,也就是IO多路复用。

3.1传统思路

每个连接对应一个socket,然后循环顺序处理各个连接,当所有socket都有数据的时候,这个方法可行。但是当某个socket数据未就绪,即使后面的socket数据就绪了,应用也会一直阻塞等待,效率低。

3.2 select

为了解决阻塞的问题,select增加了状态检查的机制。用一个 fd_set 结构体来告诉内核同时监控多个文件句柄,当其中有文件句柄的状态发生指定变化(例如某句柄由不可用变为可用)或超时,则调用返回。之后应用可以使用 FD_ISSET 来逐个查看是哪个文件句柄的状态发生了变化。

这样做,小规模的连接问题不大,但当连接数很多的时候,逐个检查状态就很慢了。因此,select 往往存在管理的句柄上限(FD_SETSIZE,默认是1024个)。同时,在使用上,因为只有一个字段记录关注和发生事件,每次调用之前要重新初始化 fd_set 结构体,开销比较大。

3.3 poll

poll 主要解决 select 的前两个问题,通过一个 pollfd 数组向内核传递需要关注的事件消除文件句柄上限,同时使用不同字段分别标注关注事件和发生事件,来避免重复初始化,但是逐个排查所有文件句柄状态效率也不高。

3.4 epoll

既然逐个排查所有文件句柄状态效率不高,如果调用返回的时候只给应用提供发生了状态变化的文件句柄,进行排查的效率就能提高很多,epoll 采用了这种设计,适用于大规模连接的应用场景。

实验表明,当文件句柄数目超过 10 之后,epoll 性能将优于 select 和 poll;当文件句柄数目达到 10K 的时候,epoll 已经超过 select 和 poll 两个数量级。

因为Linux是互联网企业中使用率最高的操作系统,epoll就成为C10K killer、高并发、高性能、异步非阻塞这些技术的代名词了。FreeBSD推出了kqueue、Linux推出了epoll、Windows推出了IOCP、Solaris推出了/dev/poll,这些操作系统提供的功能就是为了解决C10K问题。epoll技术的编程模型就是异步非阻塞回调,也可以叫做Reactor,事件驱动,事件轮循。nginx、libevent、nodejs这些都是epoll时代的产物。

3.5 libevent

由于epoll、kqueue、IOCP每个接口都有自己的特点,程序移植非常困难,于是需要对这些接口进行封装,让它们方便使用和移植,其中libevent库就是其中之一。跨平台,封装底层平台的调用,提供统一的 API,底层在不同平台上自动选择合适的调用,因此libevent非常容易移植,也使它的扩展性很强。目前,libevent已在以下操作系统中编译通过:Linux、BSD、Mac OS X、Solaris和Windows。

3.6 libev和libeio

libevent尝试给你全套解决方案,包括事件库、非阻塞IO库、http库、DNS客户端等,让它变的比较重。另外,全局变量的使用,让libevent很难在多线程环境中安全的使用,但是libevent也提供了不带全局变量的API。libev则修复这个缺陷,同时它只试图做好一件事,目标是成为POSIX的事件库,主要用于事件驱动的网络编程。

libeio是全功能的用于C语言的异步I/O库,建模风格和秉承的精神与libev类似,特性包括:异步的read、write、open、close、stat、unlink、readdir等。libeio完全基于事件库,可以容易地集成到事件库(或独立,甚至是以轮询方式)使用。libeio非常轻便,且只依赖于POSIX线程,主要提供文件I/O操作。

4

长连接锁服务开发语言选型

随着智能锁数量的增长,我们要解决的不仅仅是C10K的问题,而是C10M的问题,怎么样高效率的支持大量长连接设备的接入是我们要解决的问题。智能锁是一种双工通信设备,可以接收实时指令,也可以上报数据,这时就需要一个支持大规模连接和异步处理的框架。

Nodejs具有事件驱动、异步、非阻塞IO的特性,也有各种扩展功能的依赖包,相比Java语言来说,又具有快速开发、轻量等优势,符合目前的需求。

图1 nodejs内部构造

图1是nodejs的内部构造,node-bindings是指对底层c/c++代码的封装后和js打交道的部分,属于适配层。

底层首先是V8引擎,它就是 js 的解析引擎,它的作用就是“翻译”js给计算机处理。接下来是libuv,早期是由libev和libeio组成,后来被抽象成libuv,它就是node和操作系统打交道的部分,由它来负责文件系统、网络等底层工作。

图2 libuv架构

从图2可以看出,几乎所有和操作系统打交道的部分都离不开 libuv的支持,libuv也是node实现跨操作系统的核心所在。IO分为网络IO和磁盘IO,对于网络IO,使用epoll、kqueue之类的就可以了。但是对于磁盘IO,由于O_NOBLOCK 方式对于传统文件句柄是无效的,也就是说open、read、mkdir 之类的Regular File操作必定会导致阻塞,所以对于Regular File 来说,是不能够采用 poll/epoll 的,都是使用多线程阻塞来模拟的异步文件操作。要实现这个功能,就需要引入线程池模块,线程池默认大小是4,同时只能有4个线程去做文件i/o的工作,剩下的请求会被挂起等待直到线程池有空闲。

5

服务器性能优化

nodejs已经能帮助业务系统很好的处理大规模长连接,可优化的空间有限。但是这些长连接的建立和释放都是基于TCP的,可以在服务器层面通过优化内核参数来支持大规模的连接和并发,我们主要优化了以下几个地方:

5.1 backlog参数

backlog参数主要用于底层方法int listen(int sockfd, int backlog), 在解释backlog参数之前,我们先了解下tcp在内核的请求过程,其实就是tcp的三次握手:

图3 tcp建立过程

client发送SYN到server,将状态修改为SYN_SEND,如果server收到请求,则将状态修改为SYN_RCVD,并把该请求放到syns queue队列中。

server回复SYN+ACK给client,如果client收到请求,则将状态修改为ESTABLISHED,并发送ACK给server。

server收到ACK,将状态修改为ESTABLISHED,并把该请求从syns queue中放到accept queue。

在linux系统内核中维护了两个队列:syns queue和accept queue

syns queue

用于保存半连接状态的请求,其大小通过/proc/sys/net/ipv4/tcp_max_syn_backlog指定,一般默认值是512,不过这个设置有效的前提是系统的syncookies功能被禁用。互联网常见的TCP SYN FLOOD恶意DOS攻击方式就是建立大量的半连接状态的请求,然后丢弃,导致syns queue不能保存其它正常的请求。

accept queue

用于保存全连接状态的请求,其大小通过/proc/sys/net/core/somaxconn指定,在使用listen函数时,内核会根据传入的backlog参数与系统参数somaxconn,取二者的较小值。

如果accpet queue队列满了,server将发送一个ECONNREFUSED错误信息Connection refused到client。

前面已经提到过,内核会根据somaxconn和backlog的较小值设置accept queue的大小,如果想扩大accept queue的大小,必须要同时调整这两个参数,服务器的参数值调整如下:

如果业务代码不显式设置backlog,node程序默认是511,如图4所示:

图4 backlog默认值

当在代码中设置了backlog值之后,内核会根据somaxconn和backlog的较小值设置accept queue的大小,如图5所示:

图5 backlog优化值

5.2 limits.conf文件修改

为了让服务器能支持100万个连接,需要打破系统默认的65535个文件句柄数的限制,通过修改/etc/security/limits.conf文件来让系统能支持最多100万个连接,修改后的参数如下:

* soft core unlimited

* hard core unlimited

* soft nofile 1048576

* hard nofile 1048576

* soft nproc 32768

* hard nproc 32768

soft 指的是当前系统生效的设置值,hard 表明系统中所能设定的最大值,soft 的限制值不能高于hard 限制值,core表示限制内核文件的大小,nofile 表示打开文件的最大数目,noproc 表示进程的最大数目。

5.3 sysctl.conf文件修改

为了让系统能支持100万的连接,还需要修改fs.file-max参数,其他的参数调整如下:

kernel.randomize_va_space = 0

kernel.core_pattern = /data/crash/core.%p.%e

vm.min_free_kbytes = 1048576

fs.aio-max-nr = 1048576

fs.file-max = 1048576

kernel.panic = 0

vm.panic_on_oom = 0

5.4 压测结果

服务器配置:8核16G的CentOS机器

服务配置:pm2单进程

客户端配置:4台8核16G的CentOS机器

最大连接数受系统最大文件句柄数的影响,同时在发送业务数据的时候,每个连接还会占用一定的内存,最大连接数也受系统内存大小的影响,用默认参数和优化后的参数的对比数据如下:

图6 最大连接数对比

从图6可以看出,文件句柄参数优化对最大连接数的影响很大。由于只有4台测试客户端服务器,服务器端能承受的不带业务数据的连接数并没有达到上限,实际值要大于图中的200000。

backlog参数不影响建立的连接数,但是会影响连接建立的性能,在传数据包的时候,系统需要读写redis和mysql,对QPS的影响较大。同时,系统在接收到不同种类的数据包时,执行的操作也不一样,QPS值也有差异, backlog值为511和8000时的对比数据如下:

图7 backlog参数对QPS的影响

从图7可以看出,在不传业务数据时,backlog值大的时候,系统的性能比之前有很大提升。但是在传业务数据时,由于服务器的开销主要是在业务处理上,不同的backlog值对QPS的影响有限。

由于其他参数不影响连接的数量和性能,会影响维持长连接占用的内存等资源,如果设置过大会导致系统oom。通过优化socket缓冲区的默认值和最大值,发现对QPS的影响很小,对比数据如图8所示:

图8 socket缓冲区参数对QPS的影响

6

其他技术调研

6.1 协程

epoll 已经可以较好的处理 C10K 问题,但是如果要支持 10M 规模的并发连接,原有的技术就会有瓶颈了。从前面的C10K解决方案的演化过程中,我们可以看到,根本的思路是要高效的去阻塞,让 CPU可以干核心的任务。这意味着,不要让内核执行所有繁重的任务。将数据包处理、内存管理、处理器调度等任务从内核转移到应用程序高效地完成,让Linux只处理控制层,数据层完全交给应用程序来处理。

当连接很多时,首先需要大量的进程/线程来工作。同时系统中的应用进程/线程们可能大量的都处于 ready 状态,需要系统去不断的进行快速切换,而我们知道系统上下文的切换是有代价的。虽然现在 Linux 系统的调度算法已经设计的很高效了,但仍然满足不了10M 这样大规模的场景。

所以我们面临的瓶颈有两个,一个是进程/线程作为处理单元还是太厚重了;另一个是系统调度的代价太高了。很自然地,我们会想到,如果有一种更轻量级的进程/线程作为处理单元,而且它们的调度可以做到很快(最好不需要锁),那就完美了。

这样的技术现在在某些语言中已经有了一些实现,它们就是 coroutine(协程),或协作式例程。具体的,Python、Lua 语言中的 coroutine(协程)模型,Go 语言中的 goroutine(Go 程)模型,都是类似的一个概念。实际上,多种语言(甚至 C 语言)都可以实现类似的模型。

它们在实现上都是试图用一组少量的线程来实现多个任务,一旦某个任务阻塞,则可能用同一线程继续运行其他任务,避免大量上下文的切换。每个协程所独占的系统资源往往只有栈部分。而且,各个协程之间的切换,往往是用户通过代码来显式指定的(跟各种 callback 类似),不需要内核参与,可以很方便的实现异步。

这个技术本质上也是异步非阻塞技术,它是将事件回调进行了包装,让程序员看不到里面的事件循环。这就是协程的本质,协程是异步非阻塞的另外一种展现形式,Golang、Erlang、Lua协程都是这个模型。

6.2 设备影子

设备影子可以理解成是一个数据集,用于存储设备上报状态、应用程序期望的状态信息。每个设备有且只有一个设备影子,设备可以获取和设置设备影子以此来同步状态,这个同步可以是影子同步给设备,也可以是设备同步给影子;应用程序也可以通过API获取和设置设备影子以此来获取设备最新状态或者下发期望状态给设备。

一个典型的应用场景是:假如设备网络稳定,有很多应用程序来请求设备状态,那就意味着设备需要根据这些请求响应多次,哪怕这些响应的结果都是一样的,这样做根本就是没有必要的,而且设备本身处理能力有限,可能根本承受不了这种被请求多次的情况。

当有设备影子这个机制时,这个问题就比较好解。设备只需要主动同步状态一次给设备影子,然后多个应用程序只需要请求设备影子即可获取设备最新状态,这就做到了应用程序和设备的解耦,设备的能力得到了解放。

7

总结

本文只是大概介绍了长连接锁的服务实现和个别优化实践,实际工作中还要根据实际情况,针对具体的场景一个一个的来优化,同时还要和具体的业务结合起来,在保证稳定性的前提下,让服务更加高效强大。最后,感谢王强、来晓宾、王华健、伍思磊等同学的帮助和建议。

  • 发表于:
  • 原文链接http://kuaibao.qq.com/s/20180206G0TGNP00?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。

扫码关注云+社区

领取腾讯云代金券