一个复杂系统的拆分改造实践

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介:

1 为什么要拆分?

先看一段对话。

从上面对话可以看出拆分的理由:

1)  应用间耦合严重。系统内各个应用之间不通,同样一个功能在各个应用中都有实现,后果就是改一处功能,需要同时改系统中的所有应用。这种情况多存在于历史较长的系统,因各种原因,系统内的各个应用都形成了自己的业务小闭环;

2)  业务扩展性差。数据模型从设计之初就只支持某一类的业务,来了新类型的业务后又得重新写代码实现,结果就是项目延期,大大影响业务的接入速度;

3)  代码老旧,难以维护。各种随意的if else、写死逻辑散落在应用的各个角落,处处是坑,开发维护起来战战兢兢;

4)  系统扩展性差。系统支撑现有业务已是颤颤巍巍,不论是应用还是DB都已经无法承受业务快速发展带来的压力;

5)  新坑越挖越多,恶性循环。不改变的话,最终的结果就是把系统做死了。

2 拆前准备什么?

2.1 多维度把握业务复杂度

一个老生常谈的问题,系统与业务的关系?

我们最期望的理想情况是第一种关系(车辆与人),业务觉得不合适,可以马上换一辆新的。但现实的情况是更像心脏起搏器与人之间的关系,不是说换就能换。一个系统接的业务越多,耦合越紧密。如果在没有真正把握住业务复杂度之前贸然行动,最终的结局就是把心脏带飞。

如何把握住业务复杂度?需要多维度的思考、实践。

一个是技术层面,通过与pd以及开发的讨论,熟悉现有各个应用的领域模型,以及优缺点,这种讨论只能让人有个大概,更多的细节如代码、架构等需要通过做需求、改造、优化这些实践来掌握。

各个应用熟悉之后,需要从系统层面来构思,我们想打造平台型的产品,那么最重要也是最难的一点就是功能集中管控,打破各个应用的业务小闭环,统一收拢,这个决心更多的是开发、产品、业务方、各个团队之间达成的共识,可以参考《微服务(Microservice)那点事》一文,“按照业务或者客户需求组织资源”。

此外也要与业务方保持功能沟通、计划沟通,确保应用拆分出来后符合使用需求、扩展需求,获取他们的支持。

2.2 定义边界,原则:高内聚,低耦合,单一职责!

业务复杂度把握后,需要开始定义各个应用的服务边界。怎么才算是好的边界?像葫芦娃兄弟一样的应用就是好的!

举个例子,葫芦娃兄弟(应用)间的技能是相互独立的,遵循单一职责原则,比如水娃只能喷水,火娃只会喷火,隐形娃不会喷水喷火但能隐身。更为关键的是,葫芦娃兄弟最终可以合体为金刚葫芦娃,即这些应用虽然功能彼此独立,但又相互打通,最后合体在一起就成了我们的平台。

这里很多人会有疑惑,拆分粒度怎么控制?很难有一个明确的结论,只能说是结合业务场景、目标、进度的一个折中。但总体的原则是先从一个大的服务边界开始,不要太细,因为随着架构、业务的演进,应用自然而然会再次拆分,让正确的事情自然发生才最合理。

2.3 确定拆分后的应用目标

一旦系统的宏观应用拆分图出来后,就要落实到某一具体的应用拆分上了。

首先要确定的就是某一应用拆分后的目标。拆分优化是没有底的,可能越做越深,越做越没结果,继而又影响自己和团队的士气。比如说可以定这期的目标就是将db、应用分拆出去,数据模型的重新设计可以在第二期。

2.4 确定当前要拆分应用的架构状态、代码情况、依赖状况,并推演可能的各种异常。

动手前的思考成本远远低于动手后遇到问题的解决成本。应用拆分最怕的是中途说“他*的,这块不能动,原来当时这样设计是有原因的,得想别的路子!”这时的压力可想而知,整个节奏不符合预期后,很可能会接二连三遇到同样的问题,这时不仅同事们士气下降,自己也会丧失信心,继而可能导致拆分失败。

2.5 给自己留个锦囊,“有备无患”。

锦囊就四个字“有备无患”,可以贴在桌面或者手机上。在以后具体实施过程中,多思考下“方案是否有多种可以选择?复杂问题能否拆解?实际操作时是否有预案?”,应用拆分在具体实践过程中比拼得就是细致二字,多一份方案,多一份预案,不仅能提升成功概率,更给自己信心。

2.6 放松心情,缓解压力

收拾下心情,开干!

3 实践

3.1 db拆分实践

DB拆分在整个应用拆分环节里最复杂,分为垂直拆分和水平拆分两种场景,我们都遇到了。垂直拆分是将库里的各个表拆分到合适的数据库中。比如一个库中既有消息表,又有人员组织结构表,那么将这两个表拆分到独立的数据库中更合适。

水平拆分:以消息表为例好了,单表突破了千万行记录,查询效率较低,这时候就要将其分库分表。

3.1.1 主键id接入全局id发生器

DB拆分的第一件事情就是使用全局id发生器来生成各个表的主键id。为什么?

举个例子,假如我们有一张表,两个字段id和token,id是自增主键生成,要以token维度来分库分表,这时继续使用自增主键会出现问题。

正向迁移扩容中,通过自增的主键,到了新的分库分表里一定是唯一的,但是,我们要考虑迁移失败的场景,如下图所示,新的表里假设已经插入了一条新的记录,主键id也是2,这个时候假设开始回滚,需要将两张表的数据合并成一张表(逆向回流),就会产生主键冲突!

因此在迁移之前,先要用全局唯一id发生器生成的id来替代主键自增id。这里有几种全局唯一id生成方法可以选择。

1)snowflake:https://github.com/twitter/snowflake;(非全局递增)

2) mysql新建一张表用来专门生成全局唯一id(利用auto_increment功能)(全局递增);

3)有人说只有一张表怎么保证高可用?那两张表好了(在两个不同db),一张表产生奇数,一张表产生偶数。或者是n张表,每张表的负责的步长区间不同(非全局递增)

4)……

我们使用的是阿里巴巴内部的tddl-sequence(mysql+内存),保证全局唯一但非递增,在使用上遇到一些坑:

1)对按主键id排序的sql要提前改造。因为id已经不保证递增,可能会出现乱序场景,这时候可以改造为按gmt_create排序;

2)报主键冲突问题。这里往往是代码改造不彻底或者改错造成的,比如忘记给某一insert sql的id添加#{},导致继续使用自增,从而造成冲突;

3.1.2 建新表&迁移数据&binlog同步

1)  新表字符集建议是utf8mb4,支持表情符。新表建好后索引不要漏掉,否则可能会导致慢sql!从经验来看索引被漏掉时有发生,建议事先列计划的时候将这些要点记下,后面逐条检查;

2)  使用全量同步工具或者自己写job来进行全量迁移;全量数据迁移务必要在业务低峰期时操作,并根据系统情况调整并发数;

3)  增量同步。全量迁移完成后可使用binlog增量同步工具来追数据,比如阿里内部使用精卫,其它企业可能有自己的增量系统,或者使用阿里开源的cannal/otter:https://github.com/alibaba/canal?spm=5176.100239.blogcont11356.10.5eNr98

https://github.com/alibaba/otter/wiki/QuickStart?spm=5176.100239.blogcont11356.21.UYMQ17

增量同步起始获取的binlog位点必须在全量迁移之前,否则会丢数据,比如我中午12点整开始全量同步,13点整全量迁移完毕,那么增量同步的binlog的位点一定要选在12点之前。

位点在前会不会导致重复记录?不会!线上的MySQL binlog是row 模式,如一个delete语句删除了100条记录,binlog记录的不是一条delete的逻辑sql,而是会有100条binlog记录。insert语句插入一条记录,如果主键冲突,插入不进去。

3.1.3 联表查询sql改造

现在主键已经接入全局唯一id,新的库表、索引已经建立,且数据也在实时追平,现在可以开始切库了吗?no!

考虑以下非常简单的联表查询sql,如果将B表拆分到另一个库里的话,这个sql怎么办?毕竟跨库联表查询是不支持的!

因此,在切库之前,需要将系统中上百个联表查询的sql改造完毕。

如何改造呢?

1) 业务避免

业务上松耦合后技术才能松耦合,继而避免联表sql。但短期内不现实,需要时间沉淀;

2) 全局表

每个应用的库里都冗余一份表,缺点:等于没有拆分,而且很多场景不现实,表结构变更麻烦;

3) 冗余字段

就像订单表一样,冗余商品id字段,但是我们需要冗余的字段太多,而且要考虑字段变更后数据更新问题;

4) 内存拼接

4.1)通过RPC调用来获取另一张表的数据,然后再内存拼接。1)适合job类的sql,或改造后RPC查询量较少的sql;2)不适合大数据量的实时查询sql。假设10000个ID,分页RPC查询,每次查100个,需要5ms,共需要500ms,rt太高。

4.2)本地缓存另一张表的数据

适合数据变化不大、数据量查询大、接口性能稳定性要求高的sql。

3.1.4切库方案设计与实现(两种方案)

以上步骤准备完成后,就开始进入真正的切库环节,这里提供两种方案,我们在不同的场景下都有使用。

a)DB停写方案

优点:快,成本低;

缺点:

1)如果要回滚得联系DBA执行线上停写操作,风险高,因为有可能在业务高峰期回滚;

2)只有一处地方校验,出问题的概率高,回滚的概率高

举个例子,如果面对的是比较复杂的业务迁移,那么很可能发生如下情况导致回滚:

sql联表查询改造不完全;

sql联表查询改错&性能问题;

索引漏加导致性能问题;

字符集问题

此外,binlog逆向回流很可能发生字符集问题(utf8mb4到gbk),导致回流失败。这些binlog同步工具为了保证强最终一致性,一旦某条记录回流失败,就卡住不同步,继而导致新老表的数据不同步,继而无法回滚!

b)双写方案

第2步“打开双写开关,先写老表A再写新表B”,这时候确保写B表时try catch住,异常要用很明确的标识打出来,方便排查问题。第2步双写持续短暂时间后(比如半分钟后),可以关闭binlog同步任务。

优点:

1)将复杂任务分解为一系列可测小任务,步步为赢;

2)线上不停服,回滚容易;

3)字符集问题影响小

缺点:

1)流程步骤多,周期长;

2)双写造成RT增加

3.1.5 开关要写好

不管什么切库方案,开关少不了,这里开关的初始值一定要设置为null!

如果随便设置一个默认值,比如”读老表A“,假设我们已经进行到读新表B的环节了。这时重启了应用,在应用启动的一瞬间,最新的“读新表B”的开关推送等可能没有推送过来,这个时候就可能使用默认值,继而造成脏数据!

3.2 拆分后一致性怎么保证?

以前很多表都在一个数据库内,使用事务非常方便,现在拆分出去了,如何保证一致性?

1)分布式事务

性能较差,几乎不考虑。

2)消息机制补偿如何用消息系统避免分布式事务?

3)定时任务补偿

用得较多,实现最终一致,分为加数据补偿,删数据补偿两种。

3.3 应用拆分后稳定性怎么保证?

一句话:怀疑第三方防备使用方做好自己!


1)怀疑第三方

a)防御式编程,制定好各种降级策略;

  • 比如缓存主备、推拉结合、本地缓存……

b)遵循快速失败原则,一定要设置超时时间,并异常捕获;

c)强依赖转弱依赖,旁支逻辑异步化

  • 我们对某一个核心应用的旁支逻辑异步化后,响应时间几乎缩短了1/3,且后面中间件、其它应用等都出现过抖动情况,而核心链路一切正常;

d)适当保护第三方,慎重选择重试机制

2)防备使用方

a)设计一个好的接口,避免误用

  • 遵循接口最少暴露原则;很多同学搭建完新应用后会随手暴露很多接口,而这些接口由于没人使用而缺乏维护,很容易给以后挖坑。听到过不只一次对话,”你怎么用我这个接口啊,当时随便写的,性能很差的“;
  • 不要让使用方做接口可以做的事情;比如你只暴露一个getMsgById接口,别人如果想批量调用的话,可能就直接for循环rpc调用,如果提供getMsgListByIdList接口就不会出现这种情况了。
  • 避免长时间执行的接口;特别是一些老系统,一个接口背后对应的可能是for循环select DB的场景。

b)容量限制

  • 按应用优先级进行流控;不仅有总流量限流,还要区分应用,比如核心应用的配额肯定比非核心应用配额高;
  • 业务容量控制。有些时候不仅仅是系统层面的限制,业务层面也需要限制。举个例子,对saas化的一些系统来说,”你这个租户最多1w人使用“。

3)做好自己

a)单一职责

b)及时清理历史坑

  • 例:例如我们改造时候发现一年前留下的坑,去掉后整个集群cpu使用率下降1/3

c) 运维SOP

  • 说实话,线上出现问题,如果没有预案,再怎么处理都会超时。曾经遇到过一次DB故障导致脏数据问题,最终只能硬着头皮写代码来清理脏数据,但是时间很长,只能眼睁睁看着故障不断升级。经历过这个事情后,我们马上设想出现脏数据的各种场景,然后上线了三个清理脏数据的job,以防其它不可预知的产生脏数据的故障场景,以后只要遇到出现脏数据的故障,直接触发这三个清理job,先恢复再排查。

d)资源使用可预测

  • 应用的cpu、内存、网络、磁盘心中有数
    • 正则匹配耗cpu
    • 耗性能的job优化、降级、下线(循环调用rpc或sql)
    • 慢sql优化、降级、限流
    • tair/redis、db调用量要可预测
    • 例:tair、db

举个例子: 某一个接口类似于秒杀功能,qps非常高(如下图所示),请求先到tair,如果找不到会回源到DB,当请求突增时候,甚至会触发tair/redis这层缓存的限流,此外由于缓存在一开始是没数据的,请求会穿透到db,从而击垮db。

这里的核心问题就是tair/redis这层资源的使用不可预测,因为依赖于接口的qps,怎么让请求变得可预测呢?

如果我们再增加一层本地缓存(guava,比如超时时间设置为1秒),保证单机对一个key只有一个请求回源,那样对tair/redis这层资源的使用就可以预知了。假设有500台client,对一个key来说,一瞬间最多500个请求穿透到Tair/redis,以此类推到db。

再举个例子:

比如client有500台,对某key一瞬间最多有500个请求穿透到db,如果key有10个,那么请求最多可能有5000个到db,恰好这些sql的RT有些高,怎么保护DB的资源?

可以通过一个定时程序不断将数据从db刷到缓存。这里就将不可控的5000个qps的db访问变为可控的个位数qps的db访问。

4  总结

1)做好准备面对压力!

2)复杂问题要拆解为多步骤,每一步可测试可回滚!

这是应用拆分过程中的最有价值的实践经验!

3)墨菲定律:你所担心的事情一定会发生,而且会很快发生,所以准备好你的SOP(标准化解决方案)! 

某个周五和组里同事吃饭时讨论到某一个功能存在风险,约定在下周解决,结果周一刚上班该功能就出现故障了。以前讲小概率不可能发生,但是概率再小也是有值的,比如p=0.00001%,互联网环境下,请求量足够大,小概率事件就真发生了。

4)借假修真

这个词看上去有点玄乎,顾名思义,就是在借者一些事情,来提升另外一种能力,前者称为假,后者称为真。在任何一个单位,对核心系统进行大规模拆分改造的机会很少,因此一旦你承担起责任,就毫不犹豫地全力以赴吧!不要被过程的曲折所吓倒,心智的磨砺,才是本真。

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore     ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库 ECS 实例和一台目标数据库 RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
目录
相关文章
|
4月前
业务系统架构实践问题之定制点的大小怎么设计如何解决
业务系统架构实践问题之定制点的大小怎么设计如何解决
|
4月前
|
搜索推荐
业务系统架构实践问题之过细的扩展点颗粒度可能带来问题如何解决
业务系统架构实践问题之过细的扩展点颗粒度可能带来问题如何解决
|
4月前
|
设计模式
业务系统架构实践问题之业务间的差异性如何解决
业务系统架构实践问题之业务间的差异性如何解决
|
4月前
|
搜索推荐
业务系统架构实践问题之有效地实现“域间不可见”原则问题如何解决
业务系统架构实践问题之有效地实现“域间不可见”原则问题如何解决
|
4月前
|
缓存 Devops 微服务
微服务01好处,随着代码越多耦合度越多,升级维护困难,微服务技术栈,异步通信技术,缓存技术,DevOps技术,搜索技术,单体架构,分布式架构将业务功能进行拆分,部署时费劲,集连失败如何解决
微服务01好处,随着代码越多耦合度越多,升级维护困难,微服务技术栈,异步通信技术,缓存技术,DevOps技术,搜索技术,单体架构,分布式架构将业务功能进行拆分,部署时费劲,集连失败如何解决
|
存储 数据库 开发者
单元化架构的设计原则:让开发者、组件和数据都能透明化,同时保证业务可分片和业务自包含。
单元化架构的设计原则:让开发者、组件和数据都能透明化,同时保证业务可分片和业务自包含。
|
运维 监控 NoSQL
浅谈业务开发与非业务开发
讲述业务开发、非业务开发及两者之间区别
|
Kubernetes 架构师 Dubbo
我把整个研发中台拆分过程的一些心得总结
这里的中台架构不是平台,也不是微服务,使用的是微服务架构,这两个是不一样的概述。中台建设开源项目alinesno-cloud开始,社区建议沉淀和企业实践3年左右,于21年进行拆分,指导思想为轻中台,小前台,大平台
|
存储 前端开发 NoSQL
微服务 - 业务服务拆分分析
微服务 - 业务服务拆分分析
149 0
微服务 - 业务服务拆分分析
|
运维 监控 安全
架构-稳定性建设逻辑问题实战总结
稳定性问题分为逻辑问题和架构问题。 逻辑问题三板斧:理念正确、流程规范、刨根问底。
架构-稳定性建设逻辑问题实战总结