大型网站架构常用解决方案

廉价感情. 提交于 2019-11-27 03:17:41

每个大型网站都是由小变大的,在变大的过程中,几乎都需要经历单机架构、集群架构到分布式架构的演变。而伴随着业务系统架构一同演变的,还有各种外围系统和存储系统,比如关系型数据库的分库分表改造、从本地缓存到分布式缓存的过渡等。

在业务架构逐渐复杂的同时,保证系统的高性能、高可用、易扩展、可伸缩,使框架能有效地满足业务需要,是一个长远而艰巨的任务。本文介绍了五种相关的技术:分布式服务化架构、大流量的限流和削峰、分布式配置管理服务、热点数据的读写优化和数据库的分库分表。

值得注意的是,技术并不是越复杂越好,技术是为了更好地服务业务,只要能达到业务的需求,就是好的技术。简单说就是,即使你有实现复杂技术的能力,没有用户量和利润为基础,也难以落地实施。所以虽然下文中提到了一些框架,但是并不是每一种框架都需要你去亲自实践。很多时候,只是给你提供一个新的思路,一种新的方法,而至于是不是值得被实践,还需要得到业务和用户的考验。


分布式服务化架构

集群和分布式

首先介绍一下集群和分布式,这里有很多人不知道它们的区别,我找了网上的一张图片,非常形象地阐释了单机,集群和分布式的区别:

一言以蔽之,分布式是多个系统完成一个任务以缩短单个任务的执行时间,而集群是多个系统分摊相同的任务以提高单位时间内执行的任务数。

详细来说,集群就是当一台服务器的处理能力接近或已超出其容量上限时,与其更换一台性能更强劲的服务器,通过增加新的服务器来分散并发访问流量更加有效。这叫做服务器的横向扩容,可以实现可伸缩性和高可用性架构。而分布式则拆分了系统,即业务垂直化,就是根据系统业务功能拆分出多个业务模块,分而治之,独立部署,由此可以降低业务逻辑之间的耦合性。

下面举两个例子,方便更好的理解:

  1. 集群用例:当用户量增大时,尤其是用户跨网络,跨地域访问网站时,可以将系统中的一些静态资源数据,如图片、HTML网页等,缓存在CDN节点上,这样用户的请求会直接被距离用户最近的ISP处理,从而大幅提升系统整体响应的速度。
  2. 分布式用例:一般的大型电商网站都会拆分出首页、用户、搜索、广告、购物、订单、商品、收益结算等子系统,由不同的团队分别负责开发,部署。

值得注意的是,分布式和集群并不是相互对立的两种系统架构,而是解决问题的不同思路,甚至大部分情况下,还可以结合起来,共筑一个高性能、高可用的软件系统。例如,分布式将系统拆分成了若干业务模块,而每个业务模块都对应着集群来进行响应和处理。所以分布式可以看做集群之上的系统架构。

服务化架构,微服务和RPC

虽然将业务系统以功能为维度拆分出多个子系统,可以更清晰地规划和体现出每个子系统的职责,降低系统业务的耦合,但是系统中一定会存在较多的共享业务,这些共享业务被重复建设,产生冗余代码。而且数据库连接等底层资源必然会限制业务系统所允许横向扩展的节点的数量。为了解决诸等问题,产生了服务化架构(Service Oriented Architecture, SOA)改造。服务化可以说是分布式的更高层演化。

业务系统实施服务化改造后,原本共享的业务被拆分,形成可复用的服务,可以在最大程度上避免共享业务的重复建设、资源连接瓶颈等问题的出现。

而最近特别火的微服务,实际上也是服务化架构,只不过是细化了服务拆分过程中的粒度。

之前介绍了什么是服务化,但是说到底服务化只是一个抽象的概念,而实现服务调用的关键,就是RPC。

RPC由客户端(服务调用方)和服务端(服务提供方)两部分构成,与在同一个进程空间内执行本地方法调用相比,RPC调用需要服务调用方根据服务提供方提供的方法参数,以网络的形式远程调用指定的服务方法,执行完成后再将执行结果响应给服务调用方。

一次RPC调用主要需要经历三个步骤:

  1. 底层的网络通信协议处理;
  2. 解决寻址问题;
  3. 请求/响应过程中参数的序列化和反序列化工作。

这里的底层网络通信协议可以采用TCP;寻址问题可以通过使用服务注册中心来解决;而序列化,可以采用JSON/XML等文本格式,也可以采用二进制的协议,如protobuf,thrift。

虽然RPC协议屏蔽了底层复杂的细节处理,但是随着服务规模的扩大,使用一个RPC框架能带来很多开发和管理上的收益。目前比较流行的RPC框架有阿里的Dubbo,还有Motan,JsonRPC等,这里对其具体的使用方法不再赘述。如果想要了解的话,可以参考:

使用RPC时需要注意的一点是,防止因超时和重试引起的系统雪崩。一般的策略是RPC调用失败则自动failover到其他服务节点上,重试两次,服务调用超时就意味着失败,需要进行重试。但是如果超时时间设置地不合理,小于服务的实际执行时间,或由于网络抖动导致服务超时,在大流量的场景下,由于failover引起蝴蝶效应,请求会变为正常请求的三倍,影响到后端存储系统,导致资源连接被耗尽,从而引发系统出现雪崩。

服务化架构的组成

了解了服务化之后,我们来看看服务化架构的组成。

服务化架构的核心就是RPC,所以服务化架构的组成中也包含了RPC,就是服务的提供方和调用方,即Provider和Consumer。另外,基于RPC寻址的需求,还需要一个地方存储Provider的信息,便于Consumer调用,这就是注册中心,即Registry。另外,鉴于分布式系统的复杂性和状态多样性,我们需要对Provider、Consumer、Registry进行管理,比如服务依赖管理、权限管理、配置管理、版本管理、流量控制、服务上下线等,那么还需要一个管理端,即Administrator。

所以综上所述,一个完整的分布式服务化架构,应该包含4部分:

  1. 注册中心,Registry
  2. 服务提供端,Provider
  3. 服务消费端,Consumer
  4. 管理端,Administrator

其中,Provider将自己能够提供的服务信息登记到注册中心,同时通过心跳的方式定时更新自己的状态。Consumer从Registry获取可用服务信息后,直接与Provider建立连接进行交互。

另外,一些基于REST的服务框架(比如Spring Cloud)会增加服务路由的组件,也就是说Consumer需要经过服务路由才能请求Provider,这种方式下,其架构就是由5部分组成:

  1. 注册中心,Registry
  2. 服务提供端,Provider
  3. 服务消费端,Consumer
  4. 管理端,Administrator
  5. 服务路由,Router

REST – Representational State Transfer,全称是 Resource Representational State Transfer,通俗来讲就是:资源在网络中以某种表现形式进行状态转移。分解开来,Resource:资源,即数据;Representational:某种表现形式,比如JSON,XML,JPEG等;State Transfer:状态变化,通过HTTP动词实现。简单说就是用URL定位资源,用HTTP描述操作。也可以描述为看Url就知道要什么,看http method就知道干什么,看http status code就知道结果如何。

服务的横向拆分

之前提到的SOA服务化拆分,也叫做垂直拆分,不同的业务和功能被拆分到不同的服务中。但是当业务规模继续上升,个别服务出现了存储的瓶颈,于是需要进行存储的横向拆分设计,下面这张图片表示了服务横向拆分的四个阶段,其中计算就是指我们的业务代码,存储指数据库或者缓存:

  1. 存储没有达到存储容量以及性能的瓶颈,仍旧是单实例。
  2. 存储容量到达了瓶颈,或者单库的TPS超过了极限,由或者缓存会达到性能极限:
    • 对于数据库来说,可以按照某个业务维度拆库拆表,扩容数据库实例来承载更多的TPS。
    • 对于缓存来说,可以像数据库一样扩容更多的实例,并通过业务维度实现数据打散。
    • 无论如何,这个阶段计算层需要根据业务维度路由,找到数据所在的存储节点。
  3. 通过一个大中间件来解决所有的存储层扩展性问题。
    • 对于数据库,增加一层db proxy作为代理,帮计算层透明的完成数据路由,同时也实现数据库连接的复用。
    • 对于缓存层抛弃多实例部署方式,而是选择一款分布式缓存,比如redis clusters。
  4. 抛弃中间件,通过给每个存储分片分配独立的计算层,实现故障隔离。腾讯给这个架构方式起名叫做:set化,用业界专业术语叫做:bulkheads隔舱模式。

虽然说set方案隔离性最好,但是实施成本和改造成本都比较高,可以仅仅对核心业务做了set隔离,来尽量减小故障损失的影响面。

服务治理方案

服务拆分带来了很多的好处,但是相应的也带来了很多问题:

  • 拆得越细,系统越复杂;
  • 系统之间的依赖关系也更复杂;
  • 运维复杂度提升;
  • 监控更加复杂;
  • 出问题时定位问题更难。

基于以上问题,建立一个分布式调用跟踪系统就显得非常重要。现在的分布式调用跟踪系统大都脱胎于Google的论文 Dapper, A Large Scale Distributed Systems Tracing Infrastructure.

论文中提到了分布式调用跟踪系统的四个关键设计目标:

  1. 服务性能低损耗
  2. 业务代码低侵入
  3. 监控界面可视化
  4. 数据分析准实时

想了解分布式调用跟踪系统的具体实现过程,可以去看那篇论文,这里就不再展开了。但是需要注意的是,如果将采集到的所有数据信息都直接写入数据库中将给数据库造成较大的负载压力,因此可以将信息优先写入消息队列中,当消费端消费到信息后,再写入数据库,以达到削峰的效果。并且底层存储除了使用关系型数据库,还可以尝试使用HBase等NoSQL数据库来替代。而且,由于监控数据的时效性较高,长期保存的意义不大,所以定期清理数据库中的历史数据也是非常必要的。最后,如果跟踪系统对核心业务的性能影响比较大,那么我们可以考虑关停它,因为跟踪系统所带来的收益一定要大于损耗服务性能的缺陷,在更一般的情况下可以结合采样率在最大程度上控制损耗。

总结

以上介绍了分布式服务系统的架构。在此还需再强调一下,如果用户规模以及业务需求的复杂度还没有到量,那么最后保持现有架构不变,毕竟构建一个高性能、高可用、易扩展、可伸缩的分布式系统绝非一件简单的事情,需要解决的技术难题太多。而且如果业务没有起色,一昧的追求大型网站架构并无任何意义。

这里更多是给你提供了一个解决问题的方案,你是否会遇到这些问题,以及是否会选择这个解决方案,都不是现在能确定了的。


大流量的限流和削峰

分布式系统为什么要进行流量管制

大型互联网电商网站的主要技术挑战来自于庞大的用户规模带来的大流量和高并发,如果不对流量进行和合理管制,肆意放任大流量冲击系统,那么将导致一系列的问题出现,比如一些可用的连接资源被耗尽、分布式缓存的容量被撑爆、数据库吞吐量降低,最终必然会导致系统产生雪崩效应。

虽然限流可能会影响用户的体验,但是牺牲一点个人时间换来整体的井然有序是值得的。需要明确的是,流量管制的目的是保护系统,让系统的负载处于一个比较均衡的水位,而不是刻意得为了限流而限流,这样造成的用户体验的缺失毫无意义。

一般来说,大型互联网站通常采用的做法是通过扩容、动静分离、缓存、服务降级及限流五种常规手段来保护系统的稳定运行。下面简单介绍一下这五种常规手段:

  • 扩容:当一台服务器的处理能力接近或已超出其容量上限时,采用集群技术对服务器进行扩容。
  • 动静分离:将动态数据的静态数据分而治之,用户对静态数据的访问在CDN中获取,避免请求直接落到企业的数据中心。
  • 缓存:缓存的读写效率远胜于任何关系型数据库,合理使用缓存技术,系统可以应对大流量、高并发下的热点数据的读写问题。
  • 服务降级:当系统容量支撑核心业务都捉襟见肘时,牺牲部分功能换来系统的核心服务不受影响是非常有必要的。
  • 限流:写服务很难通过缓存和服务降级来优化,需要采用合理且有效的限流手段对系统做好保护。

合理地运用以上五种常规手段,可以使用户流量像漏斗模型一样逐层减少,让流量始终保持在系统可处理的容量范围之内:

限流方案

  1. 计数器算法
    • 池化资源技术(数据库连接池、线程池、对象池等)。确保在并发环境下连接数不会超过资源阈值。
  2. 令牌桶算法
    • 以均匀的速度向桶中放入令牌,限制流量的平均流入速率,并且可以允许出现一定程度上的突发流量。
  3. 漏桶算法
    • 以固定的速度从桶中流出流量,限制流量的流出速率,不允许出现突发流量。

以下介绍几种可选的限流实现方案

  • Guava.RateLimiter 实现基于令牌桶算法的平均速率限流。
  • Nginx 实现接入层限流。
  • 使用计数器算法实现限流:Redis集中限流/本地限流

削峰方案

之前介绍过了如何通过技术手段进行流量管制,其实也可以在业务上进行调整,对峰值流量进行分散处理,即削峰。避免在同一时间段内产生较大的用户流量冲击系统,从而降低系统的负载压力。

针对削峰的策略,可将削峰方案分为基于时间分片的削峰方案,和基于异步调用的削峰方案。

基于时间分片的削峰方案

对于基于时间分片的削峰方案,以下提供两种可选的削峰方案:

  1. 活动分时段进行实现削峰
    • 将整点的促销活动调整到多个时段进行,这样同一时间聚集的用户流量将会被有效分散,大大降低系统的负载压力。
  2. 通过答题验证实现削峰
    • 在用户下单前增加答题验证环节,那么峰值的下单请求必然会被拉长,并且靠后的请求会因为没有库存而无法顺利下单,因此同一时间对系统进行并发写的流量将会非常有限。

基于异步调用的削峰方案

对于基于异步调用的削峰方案,异步调用是指通过创建线程来实现方法的异步调用,将程序中原本的串行化执行流程变为并发/并行执行。一般情况下,在分布式环境下解决系统之间耦合以及大流量削峰的手段,是使用消息中间件来实现异步调用,即MQ消息队列。

一般而言,使用MQ进行流量削峰的经典场景,是控制并发写流量从而降低后端存储系统的负载压力。比如数据库或者分布式缓存系统,如果并发写的流量过大,容量容易瞬间撑爆导致资源连接耗尽等悲剧发生,所以需要一种削峰方案来对并发写流量进行排队处理。

通过使用MQ,我们可以先将消息写入消息队列中,若使用PULL模式,可以由消费者按照自己的处理能力获取消息来进行写操作;若使用PUSH模式,可以控制消费者的数量在合理的范围之内。

除了使用MQ来实现削峰之外,还可以使用MQ来实现系统之间的解耦。在某些情况下,不同的业务子系统之间的服务调用,即RPC,不一定是必需的,这时可以使用消息传递来替代RPC调用。例如,注册账号时调用邮件系统发送账号激活邮件,对于用户系统而言,这个调用并不是必需的,可以将消息写入消息队列中,待消费者消费后,异步完成激活邮件的发送。

总之,那些非必需的依赖,都可以通过消息传递来进行替代,从而保证进程功能的单一性。

下面介绍几个MQ的框架:

  1. ActiveMQ
    • 遵循JMS规范
  2. RocketMQ(分布式消息中间件)
    • 不遵循JMS规范,支持顺序消息,事务消息,支持PULL和PUSH。

JMS规范:由JMS Provider、Provider和Consumer三个角色构成。其中JMS Provider负责消息路由和消息传递,Provider负责向消息队列写入消息,Consumer负责订阅消息。JMS的消息模型有两种:Point-to-Point(P2P,点对点)PULL模型,Publish/Subscribe(pub/sub,发布/订阅)PUSH模型。

并不是任何情况下都适合使用MQ来进行系统之间的解耦和流量削峰等操作,对于需要同步等待调用结果的业务场景而言,使用异步化必然会对业务流程造成严重影响,甚至还会影响用户体验。


分布式配置管理服务

在实际的开发过程中,有很多地方都需要用到配置信息,在大部分情况下,我们会选择将相关配置信息配置在配置文件中,但是在集群中,维护每一个节点的配置文件十分困难。因此需要一种集中式资源配置的形式,以让所有的集群节点共享一分配置信息。这就是适用于大规模分布式场景的集中式资源配置。除此之外,在某些特殊的业务场景下,我们希望配置信息是可以在运行时发送变更的。

整理一下集中式资源管理平台有一下四个优点:

  1. 配置信息统一管理;
  2. 动态获取/更新配置信息;
  3. 降低运维人员的维护成本;
  4. 降低配置出错率。

其实简单来看,分布式配置管理服务就是典型的发布/订阅模式,获取配置信息的一方为订阅方,发布配置信息的一方为推送方。

实现分布式配置管理服务的经典框架有ZooKeeper,Dubbo就是基于此实现注册中心来进行服务的动态注册和发现。除此之外,ZooKeeper还提供了配置管理、分布式协调/通知、分布式锁及统一命名等服务。但是Zookeeper并没有实现配置信息管理页面和客户端的容灾机制,还可以选用Diamond和Disconf来提供分布式配置管理服务。


热点数据的读写优化

虽然我们可以将热点数据缓存在分布式缓存中,但是缓存系统的单点容量还是存在上限的。除此之外,由于热点数据的写操作无法直接在缓存中完成,因此并发写引起的InnoDB行锁的竞争可能会引发系统出现雪崩。下文中针对热点数据的读写优化提出了一些解决的思路和方案。

缓存技术

首先先介绍一下缓存技术。简而言之,缓存指的是将被频繁访问的热点数据存储在距离计算最近的地方,以方便系统快速做出响应。例如,静态数据可以缓存到CDN上,也可以缓存在代理服务器上,从数据库等存储系统中获取的数据信息也可以进行缓存。

针对开源本地缓存,可以试用Ehcache。但是应用程序本身的缓存就很紧张,而本地缓存是同一个进程内的缓存技术,如果缓存数据所占的内存比例比较大,肯定会影响应用程序的运行。所以在实际的开发过程中,更多采用分布式缓存,但是分布式缓存在特殊的应用场景下可能会存在单点瓶颈,所以一个很好的方案是将本地缓存与分布式缓存结合。常用的高性能分布式缓存有Redis和Memcached。

下面针对热点数据的访问,缓存可以在其中起到的优化作用,进行一下介绍。主要有热卖商品的高并发读和高并发写。

热卖商品的高并发读

分布式缓存的原理是不同的Key落到不同的缓存节点上,但是对于限时抢购的热卖商品来说,这时同一个Key必然会落到同一个缓存节点上,因此分布式缓存在这种情况下一定会出现单点瓶颈。

针对分布式缓存可能存在的单点瓶颈,以下提出了两种解决方案:

  1. 基于Redis集群的多写多读方案;
  2. 本地缓存结合Redis集群的多级Cache方案。

基于Redis集群的多写多读方案

默认情况下一个热卖商品只有一个Key,但是我们可以给它指定N个Key,在N个缓存节点上实现了冗余存储,这样在并发环境下,客户端可以针对这些Key以轮询或随机等方式实现数据访问,从而降低单个节点的负载压力。

此方案有两个问题,一是在多写情况下如何保证数据的一致性;二是由于N个Key都是提前准备好的,而不是Redis计算生成的,所以每次Redis集群发生变更时,所有的Key都需要重新计算。

针对如何保障多写时数据的一致性,有以下的解决方案:

使用Zookeeper来配置统一热卖商品的Key,当一个节点的数据发生修改时,全量更新所有的Key,这样一旦在某一个节点处写入失败,就可以直接从Zookeeper中移除该Key,从而避免数据出现脏读。当失败的节点正常后,再将其Key加到Zookeeper中即可。

本地缓存结合Redis集群的多级Cache方案

根据二八定律,限时抢购场景下的读操作比例一定会远远大于写操作比例。而本地缓存又和进程共享内存空间,所以可以将访问热度不高的商品存在分布式缓存,而本地缓存中存储访问热度较高的热卖商品。对于商品数据而言,需要配置本地缓存的更新策略,对于那些静态资源,图片、视频等都缓存在CDN上,而本地缓存只需要缓存商品详情和商品库存,并定时对分布式缓存进行轮询,更新本地缓存。这样虽然本地缓存和分布式缓存会有一定时间窗口下的数据不一致,但是对于读操作来说,我们允许在一定程度上出现脏读,等到最终扣减库存的时候再提示用户已售空即可。

在这里要注意的是,如果为本地缓存设置了TTL策略,那么当本地缓存的TTL过期,而用户流量又过大时,大量请求无法在本地缓存命中,会对分布式缓存频繁访问,导致程序的吞吐量下降。

另外由于本地缓存是通过轮询的方式从分布式缓存中拉取最新数据的,所以会存在两个缓存不一致的窗口期,如果想要缩短这个窗口期,可以引入消息队列,当分布式缓存中的商品数据修改后,再把消息写入到消息队列中,所有订阅了此Topic的本地缓存可以消费到推送的商品数据。但是这一方案设计复杂,并且增加了外围系统宕机的风险。

实时热点自动发现方案

以上方案都是基于热点Key已经确定了的情况,那么如何确定热点Key呢。有一些热点Key可以在活动开始前就提前分析出来,但是那些没有被发现并突然成为热点的数据,以及被热点数据瞬间带起来的流量就成了漏网之鱼。所以对于这种在运行时突然形成的热点,我们需要引入一种实时热点自动发现机制来进行热点保护。

具体的实施方案为,在上游系统中对相关数据进行埋点上报并异步写入到日志系统中,然后通过实时热点自动发现平台对收集到的日志数据做调用次数统计和热点分析,一旦数据符合热点条件,就立即通知系统做好热点保护。

热卖商品的高并发写

针对热点数据,除了高并发的读需求,并发扣减同一热卖商品库存的写需求是一件更加棘手的事情。

因为商品的真实库存需要存储在关系型数据库中,但是大量的并发更新热点数据都是针对同一行的,若是Mysql,这一操作必然会引起大量的线程竞争InnoDB的行锁,严重影响数据库的TPS,导致RT上升,最终可能引发系统出现雪崩。

为了避免数据库沦为瓶颈,我们可以将热卖商品库存的扣减操作转移至关系型数据外或者合理控制并发写的流量。针对如何解决热点数据的并发写问题,以下给出了几种解决方案。

关系型数据库避免超卖

直接在关系型数据库中扣减库存时,如何避免商品超卖呢?可以使用乐观锁来避免这个问题。出于性能上的考虑,我们不建议在查询操作中加排它锁,即for update,而是增加一个version字段,当一个用户成功扣减库存后,需要将version加一,这样第二个用户扣减库存时由于version不匹配,需要进行重试。

除了使用乐观锁,我们还可以使用"实际库存数≥扣减库存数"作为条件来替代version匹配,例如:

update item set stock=stock-1 where item_id = 1 and stock > 1; 

但是这并没有解决线程竞争InnoDB行锁时所引起的一系列问题,所以下面给出一些在关系型数据库外扣减库存的方案。

在Redis中扣减热卖商品库存

由于Redis的读/写能力远胜过任何关系型数据库,所以在Redis中实现库存扣减是一个很不错的替代方案。这样在Redis中存储的商品库存为实时库存,在数据库中存储的库存为实际库存。

使用Redis实现扣减库存时,需要引入分布式锁来避免超卖。分布式锁自身需要满足以下三点要求:

  1. 在任何情况下分布式锁都不能沦为系统瓶颈;
  2. 不能产生死锁;
  3. 支持锁重入。

至于分布式锁的实现方式,常见的有基于Zookeeper和Redis实现的分布式锁,这里不再赘述。

当系统获取到分布式锁并成功扣减Redis中的实时库存后,可以将消息写入到消息队列中,由消费者负责实际库存的扣减。

为了不使分布式锁沦为系统瓶颈,可以使用tryLock而是不lock来获取分布式锁,尽管这会影响商品库存的扣减成功率。

热卖商品库存扣减优化方案

以上为了解决商品超卖,无论是在数据库中扣减库存,还是在Redis中扣减库存,都必须依赖于串行化和锁机制。为了减少锁的获取次数,可以使用批量提交扣减库存的方式。

简单说就是,先对前端发起的库存扣减请求进行收集,达到阈值后再对这些请求做合并处理,获取到锁后就一次性进行库存扣减,将串行化操作变成批处理操作,大大提升了系统整体的TPS。

也可以直接使用AliSQL数据库来提升秒杀场景的性能,AliSQL针对秒杀场景做了特殊的优化。


数据库的分库分表

重要的业务数据,都是需要落盘到关系型数据库中的,如何提升关系型数据库的并行能力和检索效率就成了架构设计的关键问题。下面首先介绍一个关系型数据库架构上的演变。

关系型数据库的架构演变

关系型数据库常见的性能瓶颈主要有两个:

  1. 大量的并发读/写操作,导致单库出现难以承受的负载压力。
  2. 单表存储数据量过大,导致检索效率低下。

为了提高关系型数据库的性能,关系型数据库的架构演化趋势为:读写分离->垂直分库->水平分库分表,下面我分别介绍一下这几种架构。

读写分离:根据二八法则,80%的数据库操作都是读操作,因此读写分离可以大大降低单库的负载压力。一般采用一主多从的形式,由Master负责写操作,而Salve作为备库只开放读操作,主从直接数据保持同步。需要注意的是,如果Master存在TPS比较高的情况,Master与Salve数据库之间的数据同步是会存在一定延迟的,因此在写入Master之前最好将同一份数据落到缓存中,以避免高并发情况下,从Salve中获取不到指定数据的情况发生。

垂直分库:以关系型数据库MySQL为例,当单表数据超过500万行时,读操作就会成为瓶颈,而写操作由于是顺序写则不会有影响。因此可以根据自身业务的垂直划分,将单库中的数据表拆分到不同的业务库中,实现分而治之的数据管理和读/写操作。

水平分库分表:解决关系型数据库应对高并发、单表数据量过大的最终解决方案就是水平分库分表。水平分表就是将单库中的单个业务表拆分成n个逻辑相关的业务子表,不同的业务子表各自负责存储不同区间的数据,对外形成一个整体,这也是常说的Sharding。水平分表后的业务子表可以包含在单库中,如果单库TPS过高,也可以对单库进行水平化,将业务子表分散到n个逻辑相关的业务子库中。

Sharding中间件

以上水平分库分表将单个业务表拆分成了多个数据库中的多张业务表,需要考虑两个问题。一是明确Shard Key(路由条件),路由条件决定了数据的落盘位置;二是根据所定义的Shard Key进行数据路由,还需要定义一套特定的路由算法和规则。

例如,将1024个表均匀分布在32个数据库中,每个数据库中有32个表,这样根据Shard Key获取数据库和数据表的路由算法为:

db=shardKey%1024/32 tb=shardKey%1024%32 

一般情况下,我们使用成熟的Sharding中间件来完成数据的路由工作。Sharding中间件的架构主要有基于Proxy的架构和应用集成架构两种,基于Proxy的架构的Sharding中间件更加灵活,而应用集成架构的Sharding中间件读/写性能更高。常用的Proxy Sharding中间件有Cobar,MyCat,应用集成中间件有Shark,这里就其使用方法不再赘述。

分库分表带来的影响

虽然分库分表能很大程度上提高数据库的性能,但是也带来了一些问题,主要体现在逻辑代码的实现上。下面简述了几个常见的问题,并附带了解决问题的思路和方法。

  1. 多表之间的关联查询和外键约束无法保证

在大型的互联网企业,一旦数据库分库分表之后,对于SQL语句的编写都倾向于简单化、轻量化,而将复杂的逻辑运算上移到应用层,避免数据库成为系统的瓶颈。而外键约束也要尽可能得避免使用,使数据库的职能更加的单一,不进行额外的计算操作。所以多表联合查询都会被拆分成多条单表查询语句,而单表查询语句也有一些优势,查询语句简单,易于理解、维护和扩展;缓存利用率高等。

  1. 无法使用数据库自带的方案生成全局唯一的ID

无论是Oracle的Sequence,或是MySQL的AUTO_INCREMENT,这类生成唯一ID的方式都是面向单点的,而在分库分表的架构下,我们需要一个多机的SequenceID解决方案。可以使用Java的UUID,但是它和格式为8-4-4-4-12,作为一个ID来说太长了。还可以考虑一个独立的外围单点系统来负责生成一个兼顾唯一性和连续性ID。有些Sharding中间件也提供了生成SequenceID的API,例如Shark。

  1. 多维度的复杂条件查询

由于数据以什么样的维度分表,就会以什么样的维度落盘,最后也只能通过这种维度进行查询,想要实现满足多维度的复杂条件的查询需求就很难。可以使用Solr来完成多维度的复杂条件的查询,同时它比直接使用like进行模糊查询的效率也要高很多。

  1. 分布式事务

在分布式的环境下,本地事务已经无法保证数据的一致性了,但是引入分布式事务所带来的问题往往很复杂。常见的分布式系统中的一致性协议有:两阶段提交协议、三阶段提交协议、Paxos协议。这里不建议使用分布式事务,如果一定要保证一致性,也不要刻意去追求强一致性,刻意考虑采用基于消息中间件保证数据最终一致性的方案。

数据库的HA方案

HA在广义上是指系统所具备的高可用性。无论是数据库主从模式,还是应用程序集群,都不会因为单一节点的故障而影响系统整体服务的不可用。但是数据库搭建HA,还需要一种机制能保证主从的正常切换,目前有三种成熟的主从切换方案:

  1. 基于配置中心实现主从切换;
    • 监控系统告警后,运维人员手动修改配置中心的数据源信息。
  2. 基于Keepalived实现主从切换;
    • Master和Slave上的Keepalived程序会相互发送心跳信号,Master故障后Slave检测不到心跳就会接管写入请求。
  3. 基于MHA实现主从切换。
    • 通过保存二进制日志,最大程度的保证数据的不丢失。

订单业务冗余表需求

针对订单业务,有买家ID和卖家ID,分库分表时如果只是以其中一个维度进行数据落盘,那么最终能够查询出订单数据的只是卖家或买家中的一方。针对这种特殊的业务需求,常见的做法是对同一份订单数据进行冗余存储,即同时维护卖家订单表和买家订单表。

冗余表的实现方式有数据同步写入和数据异步写入两种,一般情况下考虑到系统的TPS,都是采用数据异步写入方案,并结合实际的订单业务,优先将数据写入买家表中。

为了保障冗余表的数据一致性,可以使用分布式事务或最终一致性方案。这里分布式事务具有复杂性和低效性,故此只介绍最终一致性方案的具体实施:

在订单写入买家表后,将消息写入消息队列中,写入买家表后也将消息写入消息队列中,这样当消费者消费到第一条消息的指定时间内没有消费到第二条消息,就可以认为数据出现了不一致,需要执行数据补偿操作。但是也有可能是网络原因导致了第二条消息的延迟,因此在数据补偿前需要优先执行幂等操作。而至于数据补偿,可以使用线上补偿,循环对比买家表和卖家表;也可以使用线下补偿,增量地对比两个表的日志。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!