高性能分布式计算与存储系统设计概要——暨2012年工作3年半总结(下)

时间:2022-08-30 03:29:35

原文:http://www.cnblogs.com/ccdev/archive/2012/12/29/2837754.html

 

在上篇里,我们主要讨论了,这个系统怎样处理大数据的“读”操作,当然还有一些细节没有讲述。下篇,我们将主要讲述,“写”操作是如何被处理的。我们都知道,如果只有“读”,那几乎是不用做任何数据同步的,也不会有并发安全问题,之所以,会产生这样那样的问题,会导致缓存和数据库的数据不一致,其实根源就在于“写”操作的存在。下面,让我们看一看,当系统需要写一条数据的时候,又会发生怎样的事情?

       同样,我们还是以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的进程来处理,我们称之为“单点模型”(也就是说,同一条数据只会有一份可用缓存,备份节点上的的不算),你可能已经猜到了,还会有“多点模型”——即同时有好几个服务进程都会负责同样的缓存数据,这是更复杂的情况,我们稍后再讨论。

高性能分布式计算与存储系统设计概要——暨2012年工作3年半总结(下) 

       现在,我们接着说“单点模型” 。这个修改和更新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导致多次无效的重读行为。

       我们继续上面的friend list。现在,我的friend list已经在缓存中被修改和更新了。无论是采用方式1还是方式2进行,在这期间,如果恰好有其它线程来读我的friend list,那么总之会受到影响,如果是方式1,该请求将等待写完毕;而如果是方式2,该请求将读2次(也可能更多,但实在不常见)。这样的处理方式,应该不是最好的,但前面已经说过了,我们的系统,主要解决:大流量高并发地读写多条数据,而不是一条。接下来,该考虑和数据库同步的事情了。

       恩,刚才说了那么多,你有没有发现,经过我修改和更新friend list后,缓存中的数据和数据库不一致了呢?显然,数据库中的数据,已经过期了,需要对其更新。现在,slave节点中的编号为9的服务进程,更新完了自己的缓存数据后(修改更新我的friend list),将“尝试”向数据库更新。注意,用词“尝试”表明该请求不一定会被马上得到满足。其实,服务进程对数据库的更新,是批量进行的,可认为是一个TaskContainer(任务容器),每间隔一段时间,或得到一定的任务数量,则成批地向数据库进行更新操作,而不是每过来一个请求,更新缓存后就更新一次数据库(你现在知道了这样做又节省了多少次数据库操作!)。那么,为什么可以这样做呢?因为,我们已经有了缓存,缓存就是我们的保障,在“单点模型”下,缓存更新后,任何读缓存的操作,都只会读到该缓存,不需要经过数据库,参看上篇中提到过此问题。所以,数据库的写更新操作,可以“聚集”,可以一定延迟之后,再进行处理。你会发现,既然如此,我就可以对这些操作进行合并、优化,比如,两个写请求都是操作同一张表,那么可以合并成一条,没错,这其实已经涉及到SQL优化的领域了。当然,你也会发现,现在缓存中的新数据还没有进行持久化,如果在这个时间点,slave节点机器down掉了,那么,这部分数据就丢失了!所以,这个延迟时间并不会太长,通常10秒已经足够了。即,每10秒,整理一下我这个服务进程中已经更新缓存未更新DB的请求,然后统一处理,如果更杞人忧天(虽然考虑数据安全性决不能说是杞人忧天,但你要明白,其实任何实时服务器发生down行为总是会有数据丢失的,只是或多或少),则延迟间隔可以更短一些,则DB压力更大一些,再次需要进行实际的考量和权衡。至此,我的friend list修改和更新请求,就全部完成了,虽然,可能在几十秒之前,就已经在页面上看到了变化(通过缓存返回的数据)。

       那么,读和写都已经讲述了,还有其它问题吗?问题还不少。刚才讨论的,都是“单点模型”。

高性能分布式计算与存储系统设计概要——暨2012年工作3年半总结(下)

       即,每一条数据库中的数据,都只有一份缓存数据与之对应。然而,实际上,“多点模型”是必须存在的,而且是更强大的处理方式,也带来同步和一致性的更多难题,即每一条数据,可能有多份缓存与之对应。即多个slave节点上的服务进程中,都有一份对应DB中相同数据的缓存,这个时候,又将如何同步呢?我们解决的方式,叫做“最终一致性”原则,关于最终一致性模型,又可以google到一大堆,特别要提出的是GoogleFS的多点一致性同步,就是通过“最终一致性”来解决的,通俗的讲,就是同一条数据,同一时刻,只能被一个节点修改。假设,我现在的业务,是“多点模型”,比如,我的friend list,是多点模型,有多份缓存(虽然实际并不是这样的),那么,我对friend list的修改和更新,将只会修改我被分配到的slave节点服务进程中的缓存,其它服务进程或slave节点的缓存,以及数据库,将必须被同步更新,这是如何做到的呢?这又要用到上篇曾提到的Notification(通知服务),这个模块虽然没有在架构图中出现,却是这个系统中最核心的一种服务(当然,它也是多份的,呵呵),即,当一条数据是多点模型时,当某一个服务进程对其进行修改和更新后,将通过向master节点提交Notificaion并通知其它服务进程或其它slave节点,告知他们的缓存已经过期,需要进行更新,这个更新,可能由所进行修改更新的服务进程,发送缓存数据给其它进程或节点,也由可能等待DB更新之后,由其它节点从DB进行更新,从而间接保证多点一致性。等等,刚才不是说,通常10秒才批量更新DB吗?那是因为在单点模型下,这样做是合理的,但在多点模型下,虽然也是批理对数据库进行更新,但这样的延迟通常非常小,可认为即时对数据库进行批量更新,然后,通过Notification通知所有有这一条数据的节点,更新他们的缓存。由此可见,多点模型,所可能产生的问题是不少的。那么,为什么要用多点模型呢?假设我有这样的业务:大数据高并发的读某一条数据,非常非常多的读,但写很少,比如一张XX门的热门图片,有很多很多的请求来自不同的用户都需要这个条数据的缓存,多点模型即是完美的选择。我许多slave节点上都有它的缓存,而很少更新,则可最大限度的享用到多点模型带来的性能提升。

       还有一些问题,不得不说一下。就是down机和定期缓存更新的问题。先说宕机,很显然,缓存是slave节点中的服务进程的内存,一旦节点宕机,缓存就丢失了,这时就需要前面我提到过的“重建缓存”,这通常是由master节点发出的,master节点负责监控各个slave节点(当然也可以是其它master节点)的运行状况,如果发现某个slave节点宕机(没有了“心跳”,如果你了解一些Hadoop,你会发现它也是这样工作的),则在slave节点重新运行之后(可能进行了重启),master节点将通知该slave节点,重建其所负责的数据的缓存,从哪重建,当然是从数据库了,这需要一定的时间(在我们拥有百万用户之后,重建一个slave节点所负责的数据的缓存通常需要几分钟),那么,从宕机到slave节点重建缓存完毕这一段时间,服务由谁提供呢?显然备份节点就出马了。其实在单点模型下,如果考虑了备份节点,则其实所有的请求都是多点模型。只不过备份节点并不是总是会更新它的缓存,而是定期,或收到Notification时,才会进行更新。master节点在发现某个slave节点宕机后,可以马上指向含有同样数据的备份节点,保证缓存服务不中断。那么,备份节点的缓存数据是否是最新的呢?有可能不是。虽然,通常每次对数据库完成批量更新后,都会通知备份节点,去更新这些缓存,但还是有可能存在不一致的情况。所以,备份节点的工作方式,是特别的,即对于每次请求的缓存都采用Pull(拉)方式,如何Pull?前面提到的版本管理系统再次出马,即每次读之前,先比较版本,再读,写也是一样的。所以,备份节点的性能,并不会很高,而且,通常需要同时负责几个slave节点的数据的备份,所以,存在被冲垮的可能性,还需要slave节点尽快恢复,然后把服务工作重新还给它。

       再说定期缓存更新的问题。通常,所有的slave节点,都会被部署在夜深人静的某个时候(如02:00~06:00),用户很少的时候,定期进行缓存更新,以尽可能保证数据的同步和一致性,且第二天上午,大量请求到达时,基本都能从缓存返回最新数据。而备份节点,则可能每30分钟,就进行一次缓存更新。咦?前面你不是说,备份节点上每次读都要Pull,比较版本并更新缓存,才会返回吗?是的,那为什么还要定期更新呢?答案非常简单,因为如果大部分缓存都是最新的数据,只比较版本而没有实际的更新操作,所消耗的性能很小很小,所以定期更新,在发生slave节点宕机转由备份节点工作的时候,有很大的帮助。

       最后,再说一下Push(推送)方式,即,每次有数据改动,都强制去更新所有缓存。这种方式很消耗性能,但更能保证实时性。而通常我们使用的,都是Pull(拉)方式,即无论是定期更新缓存,还是收到Notification(虽然收通知是被“推”了一把)后更新缓存,其实都是拉,把新的数据拉过来,就好了。在实际的系统中,两种方式都有,还是那句话,看需求,再决定处理方式。

       好了,终于写完了这篇总结,看到上篇发布后,得到了许许多多园友的鼓励和支持,在此一并感谢!相信也有不少园友,已经看到了这个系统的许多不足和瓶颈,确实,它并不是一个完美的系统,还需要不断进化。我写出这篇文章,也是希望和大家多多交流,共同进步。马上就是2013年了,希望自己能有更好的发展,也希望所有的朋友,都能更上一层楼!

        (全文完,Jone Zhang,张峻崇,2012.12.28)