最近在实现服务器业务逻辑过程中碰到了一些问题,引起了一些思考,这里就随便这段时间对服务器实现的一些理解(以下的理解均对应于单进程单线程模型)。
高并发是服务器程序的一个重要衡量指标,因此大部分框架都在说能够达到多少多少并发量。但我觉得这些框架的本质其实都差不多。无非都是使用了如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中慢慢执行。待实现后再贴出地址。