构建高可用的写服务

1、如何使用分库分表支持海量数据的写入?

1.1、是否真的要分库?(分表也是不错的选择)

分库当然能够解决存储的问题,假设原先单库只能最多存储2千万的舒亮亮,采用分库之后,存储架构变成下图所示的分库架构,每个分库都可以存储2千万数据量,容量的上限一下就提升了。

容量提升了,但也带来了很多其他问题:

  • 分库数据间的数据无法再通过数据库直接查询了,比如跨多个分库的数据需要多次查询或者借助其他存储进行聚合在查询。
  • 分库越多,出现的问题可能性就越大,维护成本越好。
  • 无法保障跨库间的事务,只能借助其他中间件实现最终一致性。

所以在解决容量问题上,可以根据业务场景选择,不要一上来就要考虑分库,分表也是一种选择。

分表是指所有的数据均存在同一个数据库实例中,只是将原先的一张大表按一定规则,划分成多张行数较少的表,它与分库的区别是,分表后的子表仍在原有库中,而分库则是子表移动到新的数据库实例里并在物理上单独部署。

那么何时使用分库,何时使用分表呢?

假设订单只是单量多而每一单的数据量较小,就适合采用分表,单条数据量小但行数多,会导致写入(因为要构建索引)和查询非常慢,但整体对容量的占用是可控的。采用分表后,大表变小表,写入时构建索引的性能消耗会变小,其次小表的查询性能也更好。如果采用分库,虽然解决了写入和查询的问题,但是每张表所占有的磁盘空间很少,也会产生资源浪费。

同时,分表除了能解决容量问题,还能在一定程度上解决分库所带来的的三个问题。

  • 分表后可以通过join等完成一些富查询,相比分库简单。
  • 分表的数据仍存储在一个数据库里,不会出现很多分库,无须引入一些分库中间件,因此维护成本和开发成本均较低。
  • 因为在同一个数据库里,也可以很好解决事务问题。

1.2、如何实现分库

在决定对数据库进行分库后,首先要解决的问题是如何选择分库的维度。不同分库维度决定了部分查询是否能直接使用数据库,以及是否存在数据倾斜问题。

1.2.1直接满足最重要的业务场景

在业务上,所有的订单数据都隶属于某一个用户的,在选择分库维度时,可以按订单归属的用户这个字段进行分库,按此维度分库后,同一个用户的订单都在某一个分库里。

订单模块处理除了提供提交订单接口外,还会提供给售卖商家对自己店铺订单进行查询及修改等功能,这些维度的查询和修改需求,在采用了按购买用户进行分库之后,均无法直接满足了。

订单模块最重要的功能是什么?

答案是保证客户的各项订单功能任何时候都能够正常使用,比如下单、下单立刻查看已购的订单信息、待支付、待发货、待配送的订单列表等。相对来说,订单里商品售卖方(即卖家)所使用的功能并不是优先级最高的,因为当我们要对卖家和买家的功能做取舍时,卖家是愿意降低优先级的。

按购买用户划分后,用户的使用场景都可以直接通过分库支持,而不需要通过异构数据(存在数据延迟)等手段解决,对用户来说体验较好,其次,同一个分库中,便于修改同一个用户的多条数据,因此也不存在分布式事务的问题。

1.2.2最细粒度随机划分

上述划分方法虽然直接满足了最重要的场景,但可能会出现数据倾斜的问题,比如出现一个超级客户(如企业客户),购买的订单量非常大,导致某一个分库数据量居多,就会重现分库前的场景,这属于最极端的情况之一。

对于倾斜的问题,可以采用最细粒度的拆分,即按数据的唯一标识进行划分,对于订单来说唯一标识就是订单号。采用订单号进行分库后,用户的订单会按Hash随机均匀地分散到某一个分库里,这样就解决了某一个分库数据不均匀的问题。

采用最细粒度分库后,虽然解决了数据均衡的问题,但又带来了其他问题:

  • 除了细粒度查询外,其他任何维度的查询均不支持,这就需要通过异构等方式解决,但异构有延时,对业务有损。
  • 其次采用最细粒度后,对于防重逻辑在数据库层面已经无法支持。比如用户对同一个订单在业务上只能支付一次这一诉求,在支付系统按支付号进行分库后便不能直接满足了,因为上述分库方式会导致不同支付订单分散在不同的分库里。此时,期望在数据库中通过订单号的唯一索引进行支付防重就不可实施了。

1.3 分库中间件选择

现在开源提供分库支持的中间件较多,整体上各类分库中间件可以分为两大类:一种是代理式,一种是内嵌式。

代理式分库中间件对于业务应用无任何侵入,业务应用和未分库时一样使用数据库,分库的选择及分库的维度对业务层完全隐藏,接入和使用成本极低。

代理式虽有使用成本低的好处,但也存在其他一些问题。

  • 代理式在业务应用和数据库间增加了一层,导致性能下降。
  • 代理式需要解析业务应用的SQL,并根据SQL中的分库字段进行路由,它需要解析和适配所有SQL语法,增加了代理模块复杂度和出错的可能性。
  • 代理层是单独进程,需要部署占用资源,带来一定的成本。

内嵌式分库中间件是将分库中间件内置在业务应用中,它只负责分库的选择,并不会解析用户的SQL。在使用时,业务应用需将分库字段传递给内嵌中间件去计算具体对应的分库。它相比代理式性能更好。

除了性能优势外,内嵌式同样存在问题。

  • 有一定侵入性,业务应用于原始单库模式相比,需要进行一定改造去适配内嵌式的API。
  • 分库在故障转移,数据迁移等运维工作时,需要业务应用感知,不过现在的一些内嵌式代理,已经具备非常良好的配置功能,在分库运维时,业务应用需要配合的内容较少。

2、如何打造无状态的存储实现随时切库的写入服务?

分库分表只解决了容量问题,并没有解决写服务的高可用问题,或者说分库分表在一定程度上增加了系统故障的概率。在读服务里,可以采用数据冗余来保障架构的高可用,但在写服务里则无法使用此方案,因为写入服务的数据是用户提交产生的,无法在写入时使用冗余来提高高可用性。写冗余需要满足CAP原则的存储支持,CAP原则最多只能同时满足两个特性,要么CP,要么AP,因此写冗余无法直接满足。

2.1、写入业务的目标是成功写入

写业务是指需要将用户传入的数据进行全部存储的一种场景:

  • 在各大网站提交的申请表单,比如落户申请、身份证办理申请、护照办理申请等;
  • 在电商、外派平台里的购物订单,其中会包含商品、价格、收货人等信息。

对于写入业务,当出现各种故障时,最重要的是保证系统可写入。

2.1.1、如何保证随时可写入?

在分库分表的架构里,假设当前只有两个分库,并且这两个分库分别部署在不同机房,当其中一个分库所处的机房出现网络故障,导致该分库不可达时,理论上系统就出现故障了,分库分表后,数据在写入时是按固定规则(比如用户账号)路由到具体分库,当某个分库不可达时,对应规则的数据就无法写入了。

但是写服务最重要的是保障数据写入,为了保障可写入,能不能在某一个分库故障后,将原有的数据全部写入当前可用的数据库?从保障数据可随时写入的角度看,此方式是可行的。

存储依然使用分库分表,但写入规则发生了变化,它不再按固定路由进行写入,而是根据当前当时可用的数据库列表进行随机写入,如果某一台数据库出现故障不可用后,则把它从当前可用数据库列表中移除,如果数据库大面积不可用,可用列表中的数据库变少时,可以适当地扩容一些数据库资源,并将它添加到当前可用的数据列中。因此此架构可以实现随时切换问题数据库、随时低成本扩容数据库,故又称它为无状态存储架构设计。

2.2.2、如何维护可用列表?

在写服务运行过程中,可以通过自动感知或人工确认的方式维护可用的数据库列表。在写服务调用数据库写入时,可以设置一个阈值,如果写入某一台数据库,在连续几分钟内,失败多少次,则可以判断此数据库故障,并将此判定进行上报。

判定某一台数据库故障并将其下线是一个挺耗费成本的事情,为了防止误剔除某一台只是发生网络抖动的数据库,可以在真正下线某一个机器前,增加一个报警,给人工确认一个机会,可以设置多少时间内,人工未响应,即可自动下线。

对于新扩容的数据库资源,通过系统功能自动加入即可,建议将顺序写入升级为按权重写入,比如对新加入的机器设置更高的写入权重,因为新扩容的机器容量时空的,更高的写入权重,可以让数据更快地在全部数据库里变得均衡。

2.2、写入后如何处理

通过数据库写入的随机化,实现了写服务高可用,但想要达成一个完整的架构方案,此设计还有几个重要的技术点需要解决。

  • 如果某一个分库故障后便将其从列表中移除,应该如何处理其中已写入的数据呢?
  • 因为数据库是随机写入,应该如何查询写入的数据呢?

在数据写入后,用户需要立即查看写入内容的场景并不太多,比如上传完论文后,你只要立刻确定论文上传成功且查看系统里论文内容和你上传的一致即可。

当数据写入随机存储成功后,可以在请求返回前,主动将数据写入缓存中,同时将此次写入的数据全部返回给前台,但此处并不强制缓存一定要写成功,缓存写入失败也可以返回成功,对时延敏感的场景,可以直接查询此缓存。

对于无状态存储中的数据,可以在写入请求中主动触发同步模块进行迁移,同步模块在接收到请求后,立即将数据同步至分库分表及缓存中。

3、如何利用依赖管控来提升写服务的性能和可用性?

在写业务的系统架构里,除了需要关注存储上的高可用,写链路上各项外部依赖的管控同样十分重要,因为即使存储的高可用做好了,也可能因为外部依赖的不可用进而导致系统故障。比如写链路上依赖的某一个接口性能抖动或者接口故障,都会导致你的系统不可用。

3.1、链路依赖的全貌

完成一个写请求时,不仅需要依赖存储,大部分场景还需要依赖各种外部第三方提供的接口。比如在创建订单时,先要校验用户有效性、再校验用户的收货地址合法性,以及获取最新价格、扣减库存、扣减支付金额等。完成上述的校验和数据获取,最后一步才是写存储。

3.2、依赖并行化

假设一次写请求要依赖二十个外部接口,可以将这些依赖全部并行化,如果一个依赖接口的性能为10ms,以串行执行的方式,请求完所有外部依赖就需要200ms,但是改为并行执行后,只需要10ms即可完成。实际场景中并没有这么精确的数字,有的外部依赖可能快一点,有的可能慢一点,实际并行执行的耗时,等于最慢的那个接口性能。

全部外部依赖的接口都可以并行是一种理想情况,接口能否并行执行的一个前置条件,就是两个接口间没有任何依赖关系,如果A接口执行的前置条件是需要B接口返回的数据才能执行,那么这两个接口则不能并行执行,按相互依赖梳理后的并行执行如下图所示,对于并行中存在相互依赖的场景,并行化后的性能等于最长字串的性能总和。

3.3、依赖后置化

虽然整个链路上会有较多外部接口,但大部分场景中,很多接口是可以后置化的,后置化是指当接口里的业务流程处理完成并返回给用户后,后置去处理一些非重要切对实时性无要求的场景,比如在提交订单后,用户只需要查看订单是否下单成功,以及对应价格、商品和数量是否正确,而对于商品的详细描述信息,所归属的商家名称等信息并不会特别关心,如果在提单的同时还需要获取这些用户不太关系的信息,会给整个提单的性能和可用率带来非常大的影响,鉴于这种情况,可以在提单后异步补齐这些仅供展示的信息。

对于一些可以后置补齐的数据,可以在写请求完成时将原始数据写入一张任务表,然后启动一个异步Worker,异步Worker再调用后置化的接口去补齐数据,以及执行相应的后置流程(比如发送MQ等)

3.4、显式设置超时和重试

即便是使用了后置化的方案,仍然会有一些外部接口需要同步调用,如果这些同步调用的接口出现性能抖动或者可用率下降,就需要通过显式设置超时和重试来规避上述问题。

3.4.1、超时设置

设置超时是为了防止依赖的外部接口性能突然变差,比如从几十毫秒飙升至十几秒及以上,进而导致你的请求被阻塞,此请求线程得不到释放,还会导致你的微服务的RPC线程池被占满,此时又会带来新的问题,进程的RPC线程池被占满后,就无法再接受任何新的请求了,你的系统基本上也就宕机了。

3.4.2、重试设置

除了超时之外,还可以对依赖的读接口设置调用失败自动重试,重试次数设置为一次,自动重试只能设置读接口,读接口是无副作用的,重试对被依赖方无数据上的影响,而写接口是有状态的,如果依赖方没有做好幂等,设置自动重试可能会导致脏数据产生。设置自动重试是为了提高接口的可用性,因为依赖的外部接口的某一台机器可能会因为网络波动、机器重启等导致当次调用超时进而失败。如果设置了自动重试,就可能重试到另外一台正常机器,保障服务的可用性。

3.5、降级方式

当依赖的读服务接口,同时该接口返回的数据只用来补齐本次请求的数据时,可以对其返回的数据采用前置缓存,当出现故障时,可以使用前置缓存顶一段时间,给依赖提供方提供一定时间去修复缓存。

对产生故障的依赖进行后置处理,比如发布微博前需要判断是否为非法内容,可能要依赖风控的接口进行合规性判断,当风控接口故障后,可以直接降级,先将微博数据写入存储并标记未校验,但此数据可能是不合规的,可以在业务上进行适当降级,未校验的数据只允许用户自己看,待风控故障恢复后再进行数据校验,校验通过后允许所有人可见。

对于需要写下游的场景,比如提单时扣减库存,当库存不够边不能下单的场景,当库存故障时,可降级直接跳过库存扣减,但需要提示用户后续可能无货,修复故障后进行异步校验库存,如果校验不通过,系统取消订单或发送消息通知客户进行人工判断是否需要等待商家补货。此方式是一种预承载,但最终有可能失败的有损降级方案。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章