谈谈服务器程序设计

最近在实现服务器业务逻辑过程中碰到了一些问题,引起了一些思考,这里就随便这段时间对服务器实现的一些理解(以下的理解均对应于单进程单线程模型)。

高并发是服务器程序的一个重要衡量指标,因此大部分框架都在说能够达到多少多少并发量。但我觉得这些框架的本质其实都差不多。无非都是使用了如epoll/kevent之类的高性能事件监听器,唯一的区别就是他们在此之上提供给应用层的便宜性。

因此从高并发的层面上来讲,其socket的高并发性能主要还是取决于如epoll/kevent的性能。

假如框架已经有了尽可能高的并发能力,应用层的实现真的就一定会高并发么?

操作系统的多进程是我最早接触到的高并发概念。OS按时间片去调度几十甚至上百个进程,让这些进程看起来像是在同时执行,这叫并发。

再看看socket并发,其实是同样的道理,让某一时间段能处理的socket请求越多,其并发量就越高。

跟OS调度不同的是,OS各进程在物理上是独立的,因此操作系统可以强行让一个进程睡眠,然后唤醒另一个进程而不会引起竞争。而同一台服务器程序上的的所有socket请求都会操作这一个服务器程序的资源,这就要求框架不能强行中断正在处理的socket请求,必须等待次请求处理完成之后才能处理下一个请求,不然整个进程内的资源都会出现竞争,想象一下几万个线程请求同一个资源的情况。这其实就是早期的非抢占式OS。

其实问题捋到这,基本上就有答案了。因为各个请求是非抢占式,因此业务逻辑必须为整个高并发程序作出贡献。努力降低每个请求的时间开销,不能造层成单点性能瓶颈。


当一个进程已经满足不了所有在线用户时,进行服务器进程拆分是势在必行的。拆分之后的分布式应用就需要处理着复杂的逻辑。

如何保证网络中断重连后的正确性,以及如何保证资源处理的一致性都是分布式里面比较经典的问题。甚至还有人总结出了cap定理。就说说我最近碰到的两个问题吧。

考虑一下有服务器进程A和服务器进程B,服务器进程A处理普通的逻辑,服务器B管理着用户的金币。当玩家需要一个烧钱的操作时,A rpc到B去扣钱,当B扣完钱之后准备回ack给A时网络断掉了。于是服务器A重新连接到B,然后重新请求扣钱。

这就会造成一个结果,客户钱被多扣了一次,面临的就可能是无尽的投诉。因此这种问题是必须要解决的。

其实现在的各大网络支付平台已经给出了解决方案,那就是订单系统,当然订单系统肯定还有其他作用。服务器A先请求B创建一个订单,然后A拿着这个订单再去B扣钱,如果中间网络断开,A重连后再次拿着同样的订单去请求B扣钱。B查询到此订单已经完成,直接返回Ok即可。这样既解决了重复扣钱的行为。其实这很像是TCP的三次握手的性质。

既然如此我们其实还可以借鉴TCP的发包机制来解决这个问题。

为每一个请求分配一个seq id, 当服务器B处理完成时就记录下这个seq id. 服务器B每接到一个请求的seq id就会检查是否已经执行过,如果已经执行过,就代表这条命令被重复发送了,直接返回结果。这样就可以解决重复发送命令的问题。使用了这种机制之后,服务器B会有很多已完成的seq id, 如何清理这些已完成的seq id就成为了一个问题。

我想了很久,其中一个解决方案就是服务器A向服务器B定时同步还没有处理完成的seq id,每当B收到这种心跳包之后,就把已经处理处理完的seq id清理掉, 然后把剩余seq id作为Ack同步到A服务器。


此段分析基于silly的coroutine机制。

在使用rpc时,容易掉入一个陷阱。就是把rpc call当作本地调用来使用。但是有一个隐藏陷阱就是,rpc call会让出当前coroutine,以便空出cpu来处理其他socket请求。这时如果先后两条socket同时操作同一个数据并且都调用rpc call,就可能会产生伪并发问题。

举个例子,服务器A接收到client请求后将本地数据d1通过rpc call到服务器B去处理,然后把结果存回服务器A的本地数d1.

如果client连续发出两条这样的请求,就会造成数据不一致的问题。因此当使用rpc时必须要小心思考是否会产生并发问题。

当然其实上面的例子只是用来说明问题。只要明确一下服务器的之间的责任即可解决。比如把数据d1从服务器A挪到服务器B,然后把rpc call的语义变掉就可以解决这个伪并发问题。

事情并不总是这么简单,我最近碰到的数据库cache同步问题,就不是那么好解决。我在数据库上层加了cache层,这样每次读取时直接读取cache,可以提高效率。

但是在更新数据库时,我碰到了一个问题。向数据库更新一个数据的流程是update cache,update db。
在调用update db时会与rpc一样让出当前处理coroutine。这样当在同一个socket请求时,同时更新两条数据进数据库,就是下面的执行顺序。
update cache A
update db A
update cache B
update db B

这样B的数据还没有更新到cache,就可能会处理新的socket请求,这时有可能会拿旧的数据B进行处理,从而产生误操作。

这个问题最终我打算实现一个channel来管理,把更新数据库部分放到一个channel中慢慢执行。待实现后再贴出地址。

谈谈协议的设计

闲来无事,最近接了个公众号玩玩,当然肯定是基于silly的:)

最初的打算是开一个daemon,在收到微信sdk callback后根据好友发送的消息来做出不同的处理。比如根据输入关键字然后去我的blog上去爬取相关信息,每天定时把最新的blog文章做群发。

在实现http client过程中,需要解析dns。虽然gethostbyname可以用来解析域名,但是整个silly底层是基于异步来实现的,而gethostbyname则是以阻塞方式解析的,因此使用gethostbyname会极大地降低整个框架的吞吐量。

向dns服务器请求解析域名时,应该首先以udp方式请求,如果服务端回应超过512字节,则会置截断标志位。客户端发现截断标志位后应该以tcp的方式重新发送请求,在发送请求时,跟udp唯一的区别是,需要在数据包最开始附上两个字节的包长。

这是我第一次遇见一份协议同时适用于udp和tcp协议的。强烈的反差感让我不禁在思考,协议到底应该以什么样的方式进行组织,才可以更方便服务端的解析。

对比一下dns的udp和tcp的协议格式,同一个请求在通过tcp发送时需要增加两个字节的包头。那是因为tcp是属于字节流会粘包,而udp是按报文发送的,换句话说,你发出去是什么,对方就会接到什么。不会有粘包的顾虑,因此tcp需要包头来进行切饱。

当然在tcp情况下即使不用包头,dns请求协议也足够描述出一个完成的数据包。那么为什么要加两个字节的包长呢?

想到这里我就不得不佩服发明’协议栈’的哥们了,说得太形象了。协议栈通过抽象分层,然后明确每一层的作用,这样不但在设计代码时可以更好的解耦,在移植时也可以通过换掉其中某一层来达到快速移植的目的。

以dns的tcp协议为例,有了两个字节的包长就可以在逻辑层以下,抽象出组包层。

组包层的实现,屏蔽掉了底层通信细节,抛给逻辑层的都是一个一个完整的包。逻辑层的实现仅仅是拿一个完整的数据包去反序列化去处理,它并不关心数据是从哪里来的。

假如我们的dns不再通过tcp来通信,而是通过串口。我们所需要做的仅仅需要重新实现一个组包层,组包层从串口读出数据并切割出一个一个完整的数据包抛给逻辑层。而之前实现的逻辑层则一点都不需要改变,从而达到了代码的最大复用性。

这一点我感触颇深,在为silly移植各种协议的过程中,发现有好多库都是把socket直接做到协议里面的,而silly为了达到高吞吐量在c语言层使用的是异步逻辑,只有在lua层才可以进行阻塞访问。因此移植一些c协议库起来就颇为制肘。

因此,在设计协议时,一定要进行抽象分层,明确责任。这样在以后协议移植时会大大增加代码的复用性。