浅谈高并发业务系统设计

 

序言

笔者工作近一年期间在做一些营销平台相关的事,负责一部分营销活动。这些营销活动并发度比较高,此处整理一下一个高并发业务系统设计的一些方法论。

1、什么是高并发

高并发是一种短时间内有大量请求到服务端的现象。对于这种现象,我们需要关注的系统指标有:

  • 响应时间(Response Time),也就是我们常说的RT。它表示的是我们的系统对一个请求的处理时间,一般在数十毫秒。如果响应时间太高,一方面,用户的体验会很糟糕;另一方面,响应时间太长也会使得我们的系统资源会吃不消,常见的比如IO资源、数据库链接池资源、线程池资源、CPU资源、甚至JVM资源,这些资源吃紧之后就会造成之后的请求无法正常处理,导致故障的产生。

  • 每秒查询数(Query Per second),也就是我们常说的QPS。他可以用来衡量系统的吞吐量,也就是在规定时间内处理请求的多少,衡量系统的处理能力。

2、为什么高并发难

按照我自己的理解,高并发无非难在几个地方:

  • 我们的系统资源CPU、内存、网络、IO是有限的,我们需要用尽可能少的系统资源去支持尽可能多的请求。任何一个资源都可能成为我们的系统瓶颈,而避免这些资源成为我们系统瓶颈的方法无非优化单次请求的资源开销、增加系统资源、减少请求数以减少资源开销。

  • 在高并发场景下,会出现并发度较低场景下不会出现的业务异常。比如对于一个HashMap,在多线程put的时候,甚至有可能会出现CPU打满的情况。

  • 在高并发场景下,一个很小的系统bug会被放大无限倍,对系统可用性要求较高

  • 在高并发场景下,我们系统依赖的二方或者三方服务抖动都有可能导致我们的系统产生故障

3、高并发的“利刃”

此小节简述一下解决高并发的一些常用解决方案,当然具体的高并发解决方案还要看具体的场景,一味堆砌反而会给系统带来不必要的复杂度和维护成本。

3.1、缓存

3.1.1、什么是缓存

提到高并发的解决方案,不得不提的一个点就是缓存(Cache)。缓存利用某些数据读多写少的特点,将这部分数据拷贝到读取速度更快的容器上,然后读取数据的时候优先从速度更快的容器读取,没有读取到再从更低层级的容器中读取,从而使得查询操作尽可能发生在读取速度更快的容器中,以提高查询速度。当然更快的容器往往意味着更贵,不可能全量数据都往里塞,往往只会往里塞一些需要频繁查询的热点数据。典型的比如CPU的L1缓存、L2缓存、L3缓存,L3缓存、L2缓存、L1缓存的大小依次变小,但是访问速度依次变大,这些缓存上会存放热点数据,当寻找数据的时候会按照L1缓存、L2缓存、L3缓存、内存的顺序依序访问,这样数据访问能做到尽可能快。

但是当告诉的缓存容器没有查询到数据时,请求还是需要到更低级的容器里进行处理。这种情况不但不会增加查询速度,反而会增加一次数据查询的时间开销。因此我们需要用读取数据的时候成功从高速缓存容器中读取到数据的几率来评估缓存的工作情况,这就是缓存命中率,其值为从缓存中读取到数据的次数/读取总次数。这个值越高表示我们缓存工作得越良好。

缓存的速度再快终究只是一种存储容器,有一定的容量限制,为了能让新的缓存数据能进入缓存,需要一定的缓存回收策略来将旧的缓存数据回收。常见的回收策略有:

先进先出算法(First In First Out, FIFO):先放入缓存的数据先被移除。

最近最少使用算法(Least Recently Used, LRU):数据最近一次使用时间最早的先被移除。

当然还有基于时间的回收策略,比如存活期(Time To Live,TTL):自缓存创建并经过指定时间之后,将缓存的数据移除。

除了上文提到的CPU多级缓存,典型的计算机科学领域的缓存有:

内容分发网络(Content Delivery Network, CDN):CDN是一组服务器,它们分布在不同位置,上面放有一些很少会去改变的静态文件,比如js文件、css文件、图片等。当用户尝试访问这些静态资源的时候,就会根据用户的位置选择一个最近的服务器节点进行访问。相当于使用户的静态资源查询操作发生在了离用户最近的服务器节点这个存储中。

域名系统(Domain Name System,DNS):DNS是负责将域名解析为ip地址的一组服务器。DNS服务器也是分有多级的,有根服务器、顶级域名服务器、权限域名服务器、本地域名服务器。在进行域名解析时,主机会对本地域名服务器递归查询,而本地域名服务器会先后对根域名服务器、顶级域名服务器、权限域名服务器进行迭代查询,各级域名服务器都会缓存一段时间的查询结果,甚至主机的操作系统、浏览器也会缓存域名解析的查询结果。显然,这里也是使用了缓存技术,让域名解析查询尽可能发生在了更快的地方。

而在系统研发中,数据一般都是去数据库访问的,但是我们知道磁盘的IO是比内存IO慢非常多的,根据这一点,我们可以将热点数据放入内存提供查询,从而提高请求的响应时间,这就是系统开发经常涉及到的缓存技术。如图:

 

3.1.2、缓存类型有哪些

缓存可以简单分为本地缓存分布式缓存,本地缓存即一台机器会对应一片单独的缓存,这种本地缓存比较简单,但是会受到单机内存容量的限制,而且在集群环境下,会有不同机器的缓存的数据一致性的问题。这时候就需要使用分布式缓存了。

本地缓存。对于本地缓存可以简单分为堆缓存堆外缓存,堆缓存由JVM管理,GC不需要开发者关心,但是这也使得在有大量缓存数据时其GC开销会很大。堆外缓存由操作系统直接管理,不会出现GC时stop the world的情况,但GC需要开发者额外关心,而且由于读取数据时需要涉及到序列化和反序列化,这也使得堆外内存的读取会比堆内存慢,当然其还是比磁盘快非常多的。因此我理解当开发者对明确了什么时候需要回收掉缓存数据时就可以使用堆外缓存,以此来减小垃圾回收器对大量缓存数据的无用扫描。无特殊要求时简单使用堆缓存即可。

分布式缓存。为了解决单机容量问题,或者对数据一致性有一定要求时就需要使用分布式缓存。分布式缓存部署在负责缓存的集群中,而非业务系统中。该分布式缓存集群会屏蔽缓存数据的数据寻址、数据复制、数据防丢等细节,当业务系统尝试访问缓存时可以从对应的分布式缓存集群中读取数据,当然这将涉及到缓存对象的序列化/反序列化,而且还有一定的网络开销。因此对于某些很热的、对时延要求很高的数据还是需要使用本地缓存来减小网络开销和序列化/反序列化开销。当然某些分布式缓存比如tair也提供了local cache的功能来解决这个问题。

3.1.3、什么时候更新缓存

什么时候更新缓存是一个难题,因为使用缓存后,数据会同时存在于数据库和缓存,而当更新数据时,数据库和缓存都需要进行更新,而无论如何更新都会导致短暂的数据不一致。目前常见的缓存更新模式有:

3.1.3.1、Cache Aside,这种更新模式在更新的时候会先更新数据库,然后把缓存失效。如图:

这里比较反直觉的一个点是,在更新的时候为什么更新完数据库是去失效缓存而不是更新缓存。假如我们更新完数据库之后是去更新缓存而不是失效缓存,存在线程A和线程B先后分别去更新数据为数据A和数据B,有可能会出现线程A更新缓存到数据A晚于线程B更新缓存到数据B的情况。这种情况会导致缓存中的数据是数据A而不是我们预期的数据B。除此之外,这种双写数据源带来了不必要的逻辑复杂度,还提前在缓存中加载了可能不会读取到的缓存。

另一个需要关注的点是,此模式的失效缓存操作是晚于更新数据库的。假如我们先失效缓存,再去更新数据库,存在线程A和线程B先后分别进行更新老数据为数据A和读取数据,线程A失效缓存却还未来得及更新数据库的时候,线程B执行了读数据的流程,读到了老数据并且将缓存中的数据置为老数据,最后线程A更新老数据为数据A。这样最后缓存中的数据为老数据,数据库中数据为数据A,显然不符合我们预期。

当然这种方法也有造成最终缓存和数据库中数据不一致的情况。存在线程A和线程B在缓存已经失效的情况下先后分别去读取数据和更新老数据为数据B,线程A的读请求会先去读数据库,读到了老数据,读操作结束后线程B开始更新数据库操作,并且将缓存置为失效,此时线程A再去将老数据写入缓存。最终缓存中的数据为老数据,数据库中的数据为数据B,显然数据不一致。但是这种情况只会在写缓存时延大于写数据库时延加上失效缓存时延时发生,导致先发出的数据库更新数据请求在后发出的缓存写老数据之前完成,概率很低。为了最大限度地避免这个问题,可以使用延时双删的方式来保证缓存中的脏数据能被失效:

// 删除对应缓存
deleteCache(key);
// 更新数据库
updateDB(data);
// 延时 时间试写缓存的响应时间而定
Thread.sleep(500);
// 再次删除对应缓存
deleteCache(key);

当然这样的延时增加了接口的响应时间,可以视情况将延时删除缓存的操作异步化。

3.1.3.2、Read/Write Through,前面提到的Cache Aside模式也有不好的地方,那就是对于调用方来说比较复杂,需要关心缓存和数据库的数据一致性问题,而Read/Write Through则是让调用方只需和缓存进行交互,如果有必要涉及到缓存和数据库的交互则客户端等待缓存层去执行。如图:

从这种模式的名字也可以看出,调用端只需要直接对缓存进行读写即可,不需要调用端来关心数据库和缓存的数据一致性问题。在读的时候直接读缓存层,缓存层来负责载入数据,而写的时候也直接写缓存层,缓存层负责将数据同步到数据库。guava中有CacheLoaderWriter来提供缓存载入数据和缓存更新数据到数据库的功能,具体的载入时机和缓存更新时机可进行配置。

需要注意的是,这个模式这里缓存层更新完数据库之后是去更新缓存而不是失效缓存,我个人理解是缓存层做了并发更新的并发控制,这个和我们的Cache Aside又有点不太一样。

3.1.3.3、Write Behind,这种模式同样只需要调用端和缓存进行交互,具体的做法为调用端直接对缓存进行读写,然后缓存异步刷到数据库。这种方式显然对调用端来说响应时间是最短的,因为只涉及到简单的缓存读写,但是这种方式会导致缓存和数据库中的数据不一致,而且有可能因为异步调度任务的执行失败、缓存数据丢失等问题导致数据没有写成功到数据库。

Cache Aside从名字上也可以看出这是一个以数据库为准的缓存使用模式,因为它是先更新数据库,之后失效缓存,这样也使得数据库中的数据是准确的。Cache Aside也可以通过延时双删最大限度地保证数据库数据和缓存数据的一致性,但由于要考虑缓存和数据库的数据一致性,使得这些代码对业务逻辑有一定侵入。而Read/Write Through和Write Behind则可以通过只和缓存层交互来避免这个问题,其中Read/Write Through模式中,调用端将数据载入和更新操作移交给缓存层来进行,缓存层去做统一的并发控制;在Write Behind模式中,调用端同样只读写缓存,缓存层会去异步将缓存数据刷到数据库,这种模式对客户端来说响应时间最短,但是有数据丢失的可能。

3.1.4、缓存使用注意事项

3.1.4.1、调用端频繁请求不存在的缓存值

假如调用端查询某个key对应的值时,这个key不存在,但是调用端一直在调用,这时这些请求就会直接打到我们的数据库上,显然这样不符合我们对缓存的要求,这样大量的请求直接打到我们的数据库上也有可能把我们的数据库给打挂。

对应的解决办法就是缓存空数据,对应一个查询关系对(key, Null),其中key为对应查询值不存在的查询键,Null为我们包装的空对象。这样调用端查询的时候我们会直接把空对象返回给他,而不需要去数据库进行一次查询。

但是假如调用端恶意调用我们的查询,先后来查询key1、key2、key3、...,这样即使我们缓存了空对象,也会使得缓存中大量垃圾数据,造成大量正常请求受到影响,缓存命中率大大降低。

这时候就需要在查询缓存前进行一次查询键的存在性判断,典型的解决方法就是使用布隆过滤器或者布谷鸟过滤器来利用较小空间进行数据的存在性判断,虽然有一定的误判率,会将不存在的数据判断为存在,但是这也大大拦截了查询键不存在的请求。

3.1.4.2、大量缓存值突然失效

通常我们在写缓存的时候会设定一个缓存的过期时间,这个过期时间一般是一个固定的时间长度,所以当写缓存的时间是集中在某个时刻时,会导致将来的某个时刻缓存集体失效,大量请求打在数据库上,大大增加了数据库的压力。

解决这个问题的第一种方法是每个缓存的失效时间在原本失效时间的基础上增加一个随机的时间段,这样当写缓存的时间集中在某个时刻时,将来缓存失效的时间会错开,而不是集体一起失效。

当然上一种方法指标不治本,最根本的方法还是需要限制住缓存集体失效时对大量请求对数据库的压力,通过并发控制保证缓存失效期间只能有一个线程能打到数据库,其他线程阻塞住,直到访问数据库的线程返回数据并且写入缓存

3.1.4.3、命中率骤降

命中率骤降一般是新上了一个功能,或者业务上有大变化。比如新上一个功能大量用到了缓存,这些缓存数据之前都不存在于缓存中,或者比如某个促销活动对某种缓存中没有数据的冷门商品进行了促销,导致这种商品被进行了大量查询,使得缓存命中率下降。这就需要上线前对新上线的功能,或者在业务变动前对这种大的业务流量变动进行评估,如果这些变动会导致缓存命中率骤降,则需要进行缓存预热,模拟这些流量,使得缓存中存放着我们将来需要读取到的数据,从而让业务变动或者系统功能变动不会导致缓存命中率骤降,给数据库带来巨大的压力。

3.1.4.4、分布式缓存响应时间飙高

前面提到了有种缓存是分布式缓存,缓存服务由专门的集群提供,其中涉及到网络传输,这就使得这个服务的响应时间是不稳定的,可能非常长。而且调用端cpu资源不足、Full GC等原因有可能导致缓存服务线程得不到调度,也可能使得缓存服务的结果迟迟无法得到。因此分布式缓存需要设置超时时间,当执行时间超过超时时间时快速失败,返回错误。而当频繁超时时说明缓存服务出问题了,需要降级分布式缓存,使用其他能承受该流量的存储,比如本地缓存,避免分布式缓存的响应时间飙高导致整个服务被拖垮。

3.1.4、缓存总结

不得不说缓存的确是解决大流量场景下的“银弹”,但是不是在系统的各个模块都应该使用缓存来堆砌性能。新增的高速缓存空间意味着成本的加大、意味着系统更加复杂、意味着缓存相关的代码可能侵入到正常的业务逻辑,数据库和缓存两份数据也意味着数据可能会出现数据的不一致性。某些业务场景不是读数据的量远大于写数据的量,或者使用数据库索引完全可以达成流量的要求,这时候就没必要使用缓存来增加自己出错的机会。

3.2、远程过程调用

3.2.1、什么是远程过程调用

前面我们提到了机器的资源比如CPU、IO、内存、网络等是有限的,但是我们的业务是需要不停扩展的,单机资源总有一天会支撑不了这些业务,好在我们可以让服务调用不发生在本机,而是去调用某台远程主机,然后获取返回结果,这就是远程过程调用(Remote Procedure Call, RPC),其实就是为了解决主机之间的远程通信问题。

RPC听起来和HTTP协议做的事很像,事实上RPC可以用HTTP来实现,利用HTTP那一套来进行协议编码解码、序列化/反序列化、数据传输,然后再加上一些RPC的东西,比如服务注册、服务发现、负载均衡等。当然如果不想像如图的HTTP的头部信息一样这样复杂,或者想要自定义协议的话,最好还是基于TCP协议来进行实现:

RPC的出现不仅使得系统能突破单机的资源限制,而且由于需要进行服务拆分,也能使得各个服务模块之间松耦合,并且也不会因为其中一个模块挂了而引起所有服务的雪崩。

3.2.2、远程过程调用怎么实现的

调用端调用服务提供端提供的服务时,显然服务提供端需要知道调用端需要调用的服务、调用涉及到服务的名称、调用的参数,除此以外调用端还需要知晓自己调用的服务哪台或者哪些(当服务提供端是一个集群的时候)机器能提供。服务调用端需要和目标主机建立链接,然后传输RPC参数,比如服务名、方法名、调用参数。当然传输过程需要涉及RPC参数序列化为二进制字节流,之后才能进行网络传输。待服务提供端拿到这些RPC参数的二进制字节流时,需要反序列化为我们能使用的对象,然后在本地的服务调用线程池中反射调用。调用完成之后服务提供端将结果序列化为二进制字节流,网络传输回调用端,最后调用端将结果的二进制字节流反序列化为对象进行使用。由于建立连接是一个开销比较大的操作,我们一般会把连接对象进行池化,池化连接之后为了保证连接的可用还需要进行心跳验活,而且网络传输还涉及到多种io方式的考量,调用方式也可分同步、异步、Future调用等,此处不细讲了。

一次调用流程已经结束,但是调用端怎么知道自己所需服务哪台或者哪些机器能提供呢?这就涉及到服务注册服务发现。服务提供端发布一个服务时会在一个注册中心注册自己能提供的服务和自己的IP地址,并且在本机开辟线程池来提供该服务的调用,这就是服务注册。而调用端需要调用时就可以去注册中心查询自己所需的服务哪些IP地址的机器能够提供,这就是服务发现。如果一个服务由多台机器提供,那还需要从这多台机器中选出一台机器,就涉及到负载均衡选址

3.2.2、远程过程调用中如何进行模块拆分

在涉及RPC的时候,哪些服务应该放在哪些模块,这就是系统模块如何拆分的问题。我们可以想一下拆分的目的是什么,是为了让模块的职责单一,让模块之间调用关系不会错综负责,对模块直接进行松耦合。比如假如对于营销的权益发放来说,最初只有一个系统,这个系统负责对用户暴露服务,处理定制业务逻辑,也负责处理包括发放条件控制、库存控制的权益发放逻辑,也负责管理权益,显然就是一个耦合在一起的系统。当然如果定制活动少、权益发放逻辑简单、权益管理也不复杂,那这样问题也不大。但是如果之后会有无数个权益不断加入到我们的系统来对接,而且活动一个接一个,定制的活动逻辑不停在变,发放逻辑也越来越复杂,那整个系统肯定会变得越来越臃肿,越来越难以维护。因此这就需要对系统进行拆分,拆分成不同自模块,模块之间RPC,从而避免单机的资源限制,并且对系统松耦合。

拆分过程我理解需要考虑的有模块之间的调用层级是否复杂、模块之间的职责是否单一、模块协同是否符合团队的结构。就拿上面的例子,我们当前是把定制活动逻辑作为了一个业务系统模块,核心发放逻辑作为一个平台系统模块,权益管理作为一个平台系统模块,调用关系为定制活动业务系统模块调用核心发放逻辑平台系统模块,核心发放逻辑平台系统模块调用权益管理平台系统模块。这样保证每个调用层级简单,且不会出现双向依赖的情况,模块之间职责尽量单一,各个模块由不同同学负责。当然这只是我们当前量级的业务的拆分方式,如果业务继续发展,各个模块又有许多不同的大分支,那当前这些模块还需要细化。因此我理解模块拆分是一个业务量级、团队规模与服务粒度之间的一个平衡

3.2.3、远程过程调用的注意点

3.2.3.1、机器一个服务响应飙高导致该机器所有服务不可用

需要注意的是这里需要不同服务开辟不同的线程池,而不是共用一个大线程池。因为不同的服务的重要性、稳定性、响应时间是不一样的。比如对于一个机器能提供一个很快很重要的服务和一个很慢却没那么重要的服务,如果共用一个线程池则会导致这个线程池中被又慢又不重要的那个服务给打满,这台机器就不能提供那个重要服务的调用了。因此不同服务对应的线程池一定要进行隔离

3.2.3.2、服务提供端服务响应时间飙高,导致调用端自己的服务不可用

调用端调用的服务是在远程的,有一定的不稳定性,没法保证RPC响应时间不会因为网络问题、服务提供端的机器状况等因素而飙高。因此一定要设置RPC的超时时间,否则可能会导致满请求把自己的服务拖垮,这个超时时间视业务敏感度决定。

3.2.3.3、远程过程调用重试导致出现脏数据

RPC是有可能超时的,而且也会出现某台服务提供端的机器不可用的情况,因此一般RPC框架会进行重试。可是超时不以为着执行失败,可能只是执行时间超出了超时时间,但是它执行成功了。而且对于用户来说他看到自己的请求失败了往往也会进行重试,这就要求我们对RPC进行幂等。幂等就是对于同样的请求,执行一次和执行同次对系统产生的影响是一样的。换句话说就是对于请求x,请求处理函数f,请求对系统的影响f(x),f(x) = f(f(x)) = f(f(f(x))) = ...恒成立。

显然我们的读请求是自带幂等的,但是写操作如果不做特殊处理的话多次请求和一次请求对系统造成的影响是不一样的。写操作分为更新和新增,常见的幂等方式有:

全局唯一ID:这种方式会根据请求生成一个全局唯一ID,这个全局唯一ID可以通过分布式缓存来做存在性判断,如果这个全局唯一ID存在说明这个请求处理过了,直接快速返回。或者使用一个去重数据库表,以全局唯一ID为唯一索引,将写去重表和处理请求的操作放在同一个数据库事务中。这样如果该请求处理过了就会因为触发了去重表的唯一索引而无法再被处理。

update的where语句:比如我们需要通过sql进行update操作,原本的sql是update table set status = 1 where id = 1,我们可以将sql改写为update table set status = 1 where id = 1 and status = 0,由于第一次执行该sql之后id为1的status就变为1了,之后再执行这个sql就不会再去修改id为1的status。这样也可以实现幂等。

3.2.3.4、服务提供端被超出自己能承受的大量请求打垮

服务提供端提供出去服务之后调用端就可以调用了,但是调用的量是由调用端决定的,因此可能会出现调用端调用量太大,超出服务提供端所能承受范围的情况。这时候就需要限流,对系统的吞吐量进行限制,超出限制的请求直接拒绝服务,以此来保护我们的系统。具体的限流方法和细节会在之后的小节阐述。

3.3、分库分表

3.3.1、什么是分库分表

我们可以通过缓存来在读多写少的业务场景避免让大量请求打到数据库,但是某些场景是不适合使用缓存的,比如用户的权益领取记录表,势必会有大量请求落在数据库,而且数据库的数据肯定会越来越多,随着数据量的增大,数据库的读写会越来越慢。为了解决这个问题一种常见的解决方法就是分库分表(sharding),分库分表可以分为水平拆分垂直拆分

水平拆分:顾名思义水平拆分就是对一个大表按照一定的拆分算法进行横向的拆分,拆分到同一个数据的不同表,甚至不同的数据库中,拆到不同的表可以避免同一个数据表数据太多导致读写速度太慢,拆到不同的数据库不但可以加快读写速度,还可以隔离开数据库的资源,比如CPU、IO、内存,但是分库会导致数据库数据库事务不再支持。拆分过程如图:

图中我们使用user_id%2作为我们的分库分表算法,将数据打散到两个数据表中。这种简单的取模方式是很常见的分库分表算法,在这里只要保证user_id这个分表键是随机的,数据就可以均匀地打散在不同的数据表中。因为我们的分表键是user_id,我们做sql操作的时候where条件一定要带上这个分表键,否则只能所有分表都查询一遍。

垂直拆分:垂直拆分就是根据业务维度将宽表进行拆解,有点像上面提到RPC服务拆解,使表更易维护。拆分过程如图:

图中假设用户的信息包含一些基础信息和工作信息,且这两个信息对应不同的服务,有不同的团队维护,我们就需要将原来的大表按照这两个业务维度进行拆分。

3.3.2、分库分表带来的问题和解决方案

分库分表分的时候确实爽了,可是这会使得之前我们单表下很简单的sql操作变得麻烦起来。但是我们需要知道的是我们的所有操作在分库分表之后需要尽量下推到数据库层面进行,这样能减少应用内存中的额外计算和开销。

3.3.2.1、分库分表之后基础查询

原来我们是一个表的时候只需要查询单表即可,但是我们做了拆分之后,原先的基础查询要发生变化了。

对于垂直拆分后的查询来说,如果拆分之后的表在同一个数据库,我们可以直接将拆分后的表进行join来查询。如果不在同一个库则可以在内存中进行join。

对于水平拆分的查询来说,我们的数据拆分成多个表了,意味这我们要根据分库分表算法路由到多个表中查询数据,然后对所有数据做union。比如原来的sql是select * from table where user_id in (10001, 10002, 10003, 10004),表被水平拆分成了table1和table2,那这个sql需要改写为(select * from table_0 where user_id in (10000, 10002)) union (select * from table_1 where user_id in (10001, 10003)),其中10000和10002这两个个user_id数据在table_0,10001和10003这两个user_id数据在table_1,如图:

3.3.2.2、水平拆分之后的join

水平拆分前的join操作很简单,因为全量数据都在一个表里,但是做了水平拆分之后的join就不一样了。这样有几种情况需要考虑:

a、join的on条件明确了分表键的值,且一定在同一个表的情况:比如原先sql是select t1.user_id, t1.age from table as t1 join table as t2 on t1.user_id = 10001 and t2.user_id = 10001。这种sql明确了join关系一定在同一个表,直接用那一个表join即可。

b、join的on条件没明确分表键的值,但是join关系一定发生在同一个表的情况:比如select t1.user, t1.age from table as t1 join table as t2 on t1.user_id = t2.user_id。这种sql虽然没有指明分表键,但是可以看出只有同一个表才能进行join,这种情况对所有拆分后的表执行该join操作,然后进行union即可。

c、join的表很小,且很少改动却又经常join的表的情况:比如原先sql是select *from table join config on XXX,其中config是一个很小而且很少改动的表。我们进行水平拆分前,全量数据一定是在同一个数据库的,想要join当前数据库的一个其他表很简单。可是拆分之后表可能会打散到不同的数据库,但是我们再去join一个数据库的表就比较麻烦了。但是对于很小,且很少改动,又经常需要join的表,可以使用小表广播。这些小表会被复制到各个分库,保证join的正常进行。

d、不再可下推到数据库执行的情况:比如原先sql是select * from table as t1 join table as t2 on t1.age = t2.weight。这种sql我们无法让拆分后的数据库来执行,因为已经跨表甚至垮库了,因此内存计算在所难免。常见的内存join计算的方式有三种:

sort merge join。对于上面的那个sql,我们可以先对一份数据按照age排序,再对一份数据按照weight排序,这样判断age等于weight的行直接做归并即可,不需要每次对数据从头扫到尾。而排序操作和归并操作都不需要完全在内存中进行,这也使得这种方法允许数据特别大的情况。这种方式虽然需要排序,但是如果数据本来就已经排序好了的时候这种方法就可以发挥很好的作用。

hash join。hash join一般会使用join关系中较小的那个表的join键建立一个hash表,然后用另一个表的join键来进行关联,找出所匹配的行。同样的,如果建立的hash表太大,无法一次放入内存,则需要进行partition。这种方法显然适合在两个表数据量相差特别大的情况,使用小表来建立hash表。

index nested loop。对于两个表,这种连接方式遍历其中一个表的数据,然后去根据这个表的数据的关联键去查询另一个表的数据,以此来实现join。这种方式适合遍历的表比较小,并且被查询的表在使用关联键查询的时候索引工作良好的情况

3.3.2.3、外键约束

显然拆分到多个库之后我们的外键约束会受到影响。外键的使用可以减少应用的开发量,但是外键有一定的开销,而且锁主键表时会导致外键表也被锁住,因此我们一般不使用外键,而是在应用层面来做这个判断约束。另一方面我们进行数据拆分之后是希望数据在同一个数据库内是内聚的,而不会需要一个数据库的数据受到另一个数据库的数据的约束。比如常见的,我们对用户维度的数据进行水平拆分的时候,我们总是希望拆分之后同一个用户的数据落在同一个库里,这个可以通过分库分表时模运算user_id%n中n的值固定来保证。

3.3.2.3、数据库唯一ID的生成

在单表的时候生成唯一ID很简单,直接让数据库表ID自增即可,可是现在我们将数据库表进行了拆分,ID自增的方式用不了了。需要注意的是我们需要这个ID不但满足唯一性,还要满足一定的有序性,这样对数据库的索引比较友好,而不会使得索引中的B+树频繁节点分裂。生成唯一、且基本有序的方法主要有:

不同分表设置不同的起始ID。比如存在4个分表table1、table2、table3、table4,可以把他们的ID初始值分别设置为1,2,3,4,然后让他们的ID步长设置为4,这样他们的ID不会重复,且在各个数据库中的ID可以满足有序。这种方法比较简单,而且利用了数据库的自增ID,缺点是扩容的时候需要重新设置步长,维护成本较高。而且这种方法每获取一次ID都要读一次数据库。

snowflake算法。这种方法能按照时间有序生成ID,效率也比较高,但是这种方法生成ID会依赖系统的时钟,一旦系统时钟回拨则有可能导致ID乱序甚至重复。而且在分布式环境中,时钟很难保证严格一致,这样会导致ID不是严格有序。

每台机器取一段id号,然后在内存中进行分配使用。这是我们当前使用比较多的一种方式,如图:

用专门的一张表来进行序列号段分配,分配的序列号段长度为步长。比如假如步长为1000,应用机器的序列号段用完之后就会将这张表中value新增1000,来表示自己拿到了当前value值往后1000个序列号。拿到这些序列号之后应用机器就可以在内存中自己将这些序列号分配给对应的数据行,然后写存储数据库。这个步长视ID使用速度决定,步长太长会导致应用机器重启时大量步长被浪费,步长太短又会导致我们的序列号段分配表的读写压力较大。

3.3.2.3、排序、group by、聚集函数、分页

显然,拆分之后我们的group by、聚集函数、排序、分页都会变得和原来的单表不一样。而且由于数据量可能很大,这里的这些方法都要保证无需将全量数据加载入内存,当然这里还是不可避免地会涉及到大量内存计算。

排序:通常拆分之后我们的排序方法为merge sort,拆分之后的多个表分别进行排序,然后进行多路归并。

group by:至于group by操作,可以先将数据进行排序,排序之后能group by的数据一定是相邻的数据,因此排序之后简单遍历排序后的数据即可做到group by。

聚集函数:聚集函数有count、sum、avg、max、min,原先的聚集函数会涉及到一定的改写,比如原来对单表的count会变成对拆分后所有表取count,再求和。原先单表sum会变成对拆分后所有表取sum,再求和。原先单表avg变成对拆分后的所有表求总sum,求总count,然后计算sum/count。而对于max和min,需要计算所有拆分表的max和min,然后汇总出对应最值。

分页:分页又分为非排序分页select * from table limit a, b和排序分页select * from table order by age limit a, b。对于非排序分页只需要将分页所需数据拆分到不同分表中,然后将得到的数据等比例或者等步长混合。但是对于排序分页则需要在不同的分表中执行select * from table order by age limit 0, (a+b),取出各个表中前(a+b)大小个数据,然后再在内存中根据各个分表的前(a+b)大小个数据求出总排名a到a+b的数据。可以看出这个排序逻辑是比较复杂的,因此sql中的a不应该过大。

3.3.2、分库分表总结

可以看到分库分表能将数据拆到不同表甚至不同数据库,减少读写的响应和对单库上的资源抢占,但是这也会带来很多问题,原来一个sql能解决的问题会变得很复杂。不过好在有一些分库分表中间件,比如公司的TDDL,可以让用户感知不到分库分表逻辑,正常当成单表使用就行。而TDDL内部会根据用户的sql和分库分表情况进行sql解析和sql改写,尽可能将数据操作下推到数据库执行,以减少不必要的内存开销和额外计算。有必要时,TDDL也会在中间层进行数据汇总计算,将得到的数据集结果返回。

除此以外,我们在分库分表的时候还应该关心拆分之后的单库的数据是否是内聚的,比如一个用户在该系统的内聚的一些数据,在进行拆分后是否仍然还是在一个库的。以及还需要评估数据量是否真的大到了需要分库分表的地步,是否可以通过删除无用历史数据或者添加索引来解决即可。

3.4、队列

3.4.1、什么是队列

队列(Queue)是一种数据结构,满足数据先入先出(First In First Out, FIFO)的条件。这种看起来不起眼的数据结构有时候却能发挥很好的效果。

在系统开发中对请求的处理很多时候是同步、串行进行的,比如对于发奖流程发奖条件判断->缓存中库存扣减->缓存库存扣减同步到数据库->奖品发放,有一个同步串行依次执行的过程。但是可能事实上我们不需要等待着缓存库存同步到数据库结束就可以去处理奖品发放流程,只需要保证缓存库存扣减同步到数据库在未来的什么时候结束掉即可。我们这时候就可以库存同步将流程的处理请求放入队列,处理这个流程的一端空闲时可以来队列取出请求,然后进行处理。这样我们就无需按照原先的关系进行调用,只需要进行发奖流程发奖条件判断->缓存中库存扣减->奖品发放的调用,响应时间也会减少,而库存同步保证会在未来的某个时刻完成即可。

事实上队列的作用有:

异步化。将非核心流程异步化可以提高请求的响应时间,因为我们可以从队列中批量取请求,这还能使得这些非核心流程能够集中在一起批量处理。

松耦合。比如对于营销平台系统会进行权益发放,某些业务系统希望权益发放出去之后进行某些操作。但是显然作为一个平台方不可能去反过来调用业务系统,因为调用关系不合理,而且将来还会有更多的业务系统需要这样的操作。这时候就可以将权益发放成功的数据信息,我们称之为消息(Message),放入队列中。而业务系统就可以取出消息,进行后续的处理。这样就成功实现了松耦合。

消峰填谷。很多时候流量不是均衡的,某些时段会出现一个峰值,但是我们的系统的无法承载下这个峰值。假如业务允许这个流量处理有一定延时,我们就可以将这些请求放入队列,请求处理端按照自己的处理能力从队列中取请求进行处理。这样就做到了流量的消峰填谷。

3.4.2、队列如何实现

当前市面上是有很多队列中间件的,比如metaq、notify、kafka,我们可以简单设想下假如我们来实现一个简单的队列,需要考虑些什么。首先先来明确一下这个队列可能有些什么核心功能。根据前面我们对队列作用的分析,这个队列核心功能不外乎能暂存消息发送端发来的数据,并且能在合适的时间点让消息接收端来把消息取走,或者把消息发送给消息接收端。为了将这个队列和我们的系统松耦合,这个队列相关的服务一般是独立出来部署在远程机器上。

3.4.2.1、消息存储

我们的队列是需要暂存发送端发来的数据的,可是这些消息数据存放在哪呢?常见地,我们可以会想到文件和数据库。显然对于数据库我们可以开箱即用,而且数据库可靠性也比较好。而如果对性能要求比较高,而且有某些定制要求,也可以使用文件系统自建索引来进行数据存储。

3.4.2.1、消息发送

ack机制。我们的队列存储发送端发来的数据的最终目的是为了将消息发出去,这里就会涉及到主机之间的远程通信,其实就是我们上面说的RPC。在这里的队列设计时,消息从队列接收到投递出去至少会有两种RPC,一次是我们的队列接收了消息发送端发来的消息数据,一次是我们的队列和消息接收端的远程通信,让消息接收端拿到对应消息数据。但是这种简单的两次RPC没法保证消息接收端一定接收到了队列的消息,因为网络和机器状态都是不稳定的,可能由于网络问题消息接收端接收消息失败了,有可能消息接收端因为暂时不方便处理或者业务处理失败了。这些情况相当于消息发送失败了,因此我们需要消息接收端做一个消息的确认收到,如果这个消息在一段时间内没有被消息接收端确认,则需要过一段时间重传这个消息,这个确认我们称之为ack(Acknowledge, ack)

消息重复问题。我们引入了ack之后,由于有ack的存在,只要做了消息在队列中的持久化,消息就不会丢失了。可是有些时候网络抖动消息接收端接收到了消息,却没有把ack发出来,于是队列认为消息接收端没有接收到消息,便进行了重传,因此不可避免地出现了消息重复。这种消息重复是因为ack机制导致的,不可避免的现象。如果要求完全不出现重复也可以,但是就会出现消息丢失的情况。这种消息重复根本原因还是因为网络抖动导致的消息接收端ack没有发出,为了解决这个问题可以在接收端进行消息去重,像处理RPC幂等那样使用唯一ID去重表或者保证业务逻辑的本身的幂等性。

消息顺序问题。消息发送端和消息接收端一般都是一个集群,如何保证消息接收端收到消息能够顺序消费呢?我们可以对消息进行标号,消息接收端收到消息之后按照消息标号顺序进行消费。除此之外,我们还可以让显然可以让消息发送端和消息接收端都单线程发送单线程接收,但是对性能影响非常大。为了让效率和顺序保持均衡,我们可以让需要满足顺序的消息进入一个队列,其他消息进入其他队列。

消息传输模式。我们需要让队列中的数据传输到消息消费端,可是应该让消息消费端从队列中拉取还是让队列向服务端主动推送数据呢?这就涉及到消息传输模式。

        pull模式。消息接收端可以主动从队列中拉数据,这就是pull模式。pull模式由消息消费端直接从队列拉取数据,消费者端可以根据自己的负载来决定拉取数据的时机,但是这会导致消费者端处理消息会有一定延时。除此以外,pull模式可以在从队列拉取消息的时候尽可能拉取足够多的消息来进行消息批量处理

        push模式。队列直接推送消息到消息消费端,这就是push模式。这使得消息处理实时性能够很好,但是队列无法知道消息消费端的负载情况和消息处理能力,而且对于不同消费能力的机器显然推送相同量的消息是不合理的。普通pull模式在队列没消息时也会去拉取数据,会导致一些无用拉取的开销。

        长轮训模式。长轮训模式是一种特殊的pull模式,它允许消息消费端在队列中没有消息的时候阻塞住,等待消息到来,在消息到来或者超时之后阻塞结束。但是如果消息一直都有,相当于消息消费端一直在对队列执行间隔很短的拉取动作,为了减少网络IO的开销,可以在pull的时候加一个buffer,buffer满了或者超时了返回给消息消费端。而且由于buffer容量是已知的,队列也可以根据buffer容量来评估消息消费端的负载。

事务消息。(未完,待续)

  • 4
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值