在上篇里,我们主要讨论了,这个系统怎样处理大数据的“读”操作,当然还有一些细节没有讲述。下篇,我们将主要讲述,“写”操作是如何被处理的。我们都知道,如果只有“读”,那几乎是不用做任何数据同步的,也不会有并发安全问题,之所以,会产生这样那样的问题,会导致缓存和数据库的数据不一致,其实根源就在于“写”操作的存在。下面,让我们看一看,当系统需要写一条数据的时候,又会发生怎样的事情?
同样,我们还是以friend list为例。现在,我登陆了这个网站,获取了friend list之后,我添加了一个好友,那么,我的friend list必定要做修改和更新(当然,添加好友这一个动作肯定不会只有修改更新friend list这一个请求,但我们以此为例,其它请求也是类似处理),那么,这个要求修改和更新friend list的请求,和获取friend list请求类似,在被slave节点中的服务进程处理之前,也是先通过DNS负载均衡,被分配到合适的master节点,再由master节点,分配到合适的相对空闲的负责这一功能的slave点上。现在假设,前面我们已经讲过,获取friend list这样的请求,非常常用,所以,提供这一供能的服务进程将会有多份,比如,有10份,服务进程编号为0~9,同时运行在10个(也可能仅运行在1个~9个slave节点上!)slave节点上,具体分配请求的时候,选择哪一个slave节点和哪一份服务进程呢?这当然有许多种规则去影响分配策略,我们就举一个最简单的例子,采用用户id对10取模,得到0~9的结果,即是所选择的服务进程编号,假设我的用户id尾号为9,那么我这个请求,只会被分配到编号为9的服务进程去处理(当然,所有用户id尾号为9的都是如此),编号为9的服务进程,也只负责为数据库中用户id尾号为9的那些数据做缓存,而用户id尾号为0~8的缓存则由其它服务进程来处理。如果所需的请求是以刚才这种方式工作的,那么现在我要求修改和更新friend list的这个请求,将只会被分配到服务进程编号为9的进程来处理,我们称之为“单点模型”(也就是说,同一条数据只会有一份可用缓存,备份节点上的的不算),你可能已经猜到了,还会有“多点模型”——即同时有好几个服务进程都会负责同样的缓存数据,这是更复杂的情况,我们稍后再讨论。
现在,我们接着说“单点模型” 。这个修改和更新friend list的请求到了编号为9的服务进程中后,如何被处理呢?缓存肯定先要被处理,之后才考虑缓存去和数据库同步一致,这大家都知道(否则还要这个系统干嘛?)大家还知道,只要涉及到并发的读写,就肯定存在并发冲突和安全问题,这又如何解决呢?我们有两种方式,来进行读写同步。
1、 第一种方式,就是传统的,加锁方式——通过加锁,可以有效地保证缓存中数据的同步和正确,但缺点也非常明显,当服务进程中同时存在读写操作的线程时,将会存在严重的锁竞争,进而重新形成性能瓶颈。好在,通常使用这种方式处理的业务需求,都经过上述的一些负载均衡、分流措施之后,锁的粒度不会太大,还是上述例子,我最多也就锁住了所有用户id尾号为9的这部分缓存数据更新,其它90%的用户则不受影响。再具体些,锁住的缓存数据可以更小,甚至仅锁住我这个用户的缓存数据,那么,锁产生的性能瓶颈影响就会更小了(为什么锁的粒度不可能小到总是直接锁住每个用户的缓存数据呢?答案很简单,你不可能有那么多的锁同时在工作,数据库也不可能为每个用户建一张表),即锁的粒度是需要平衡和调整的。好,现在继续,我要求修改和更新friend list的请求,已经被服务进程中的写进程在处理,它将会申请获得对这部分缓存数据的锁,然后进行写操作,之后释放锁,传统的锁工作流程。在这期间,读操作将被阻塞等待,可想而知,如果锁的粒度很大,将有多少读操作处于阻塞等待状态,那么该系统的高性能就无从谈起了。
2、有没有更好的方法呢?当然有,这就是无锁的工作方式。首先,我们的网站,是一个读操作远大于写操作的网站(如果需求相反,可能处理的方式也就相反了),也就是说,大多数时候,读操作不应该被写操作阻塞,应优先保证读操作,如果产生了写操作,再想办法使读操作“更新”一次,进而使得读写同步。这样的工作方式,其实很像版本管理工具,如svn的工作原理:即,每个人,都可以读,不会因为有人在进行写,使得读被阻塞;当我读到数据后,由于有人写,可能已经不是最新的数据了,svn在你尝试提交写的时候,进行判断,如果版本不一致,则重新读,合并,再写。我们的系统也是按类似的方式工作的:即每个线程,都可以读,但读之前先比较一下版本号,然后读缓存数据,读完之后准备返回给Web时,再次比较版本号,如果发现版本已经被更新(当然你读的数据顶多是“老”数据,但不至于是错误的数据,Why?还是参考svn,这是"Copy and Write"原理,即我写的那一份数据,是copy出来写的,写完再copy回去,不会在你读出的那一份上写),则必须重新读,直到读到的缓存数据版本号是最新的。前面已经说过,比较和更新版本号,可认为是原子操作(比如,利用CAS操作可以很好的完成这一点,关于CAS操作,可以google到一大堆东西),所以,整个处理流程就实现了无锁化,这样,在大数据高并发的时候,没有锁瓶颈产生。然而,你可能已经发现其中的一些问题,最显著的问题,就是可能多读不止一次数据,如果读的数据较多较大,又要产生性能瓶颈了(苦!没有办法),并且可能产生延迟,造成差的用户体验。那么,又如何来解决这些问题呢?其实,我们是根据实际的业务需求来做权衡的,如果,所要求的请求,允许一定的延迟存在,实时性要求不是最高,比如,我看我好友发的动态,这样的缓存数据,并不要求实时性非常高,稍稍有延迟是允许的,你可以想象一下,如果你的好友发了一个状态,你完全没有必要,其实也不可能在他点击“发布”之后,你的动态就得到了更新,其实只要在一小段时间内(比如10秒?)你的动态更新了,看到了他新发布了状态,就足够了。假设是这样的请求,且如果我采用第1种加锁的方式所产生的性能瓶颈更大,那么,将采用这种无锁的工作方式,即当读写有冲突时候,读操作重新读所产生的开销或延迟,是可以忍受的。比较幸运的是,同时有多个读写线程操作同一条缓存数据导致多次的重读行为,其实并不是总是发生,也就是说,我们系统的大数据并发,主要在多个进程线程同时读不同条的数据这一业务需求上,这也很容易理解,每个用户登陆,都是读他们各自的friend list(不同条数据,且在不同的slave节点上),只不过,这些请求是并发的(如果不进行分布式处理会冲垮服务器或数据库),但是并不总是会,许多用户都要同时读某一条friend list同时我还在更新该条friend list导致多次无效的重读行为。