【51CTO.com原创稿件】微服务架构实施后,不少通用数据访问会拆分成服务,通用业务也会拆分成服务,站点与服务之间的依赖关系会变得复杂,服务与服务之间的调用关系也会变得复杂。
如果水平拆分/垂直拆分得不合理,系统之间会严重耦合,如何消除微服务架构中的系统耦合?
2018 年 5 月 18 - 19 日,由 51CTO 主办的全球软件与运维技术峰会在北京召开。
在“微服务架构设计”分会场,58 速运 CTO 沈剑带来了《58 速运微服务架构解耦最佳实践》的主题分享。
本文将按照如下几个方面来展开分享:
- 微服务之前,系统中存在的耦合问题
- 微服务架构,存在什么问题?
- 58 速运的微服务实践
- 总结
相对于 58 同城,58 速运属于一家初创型公司。在早期,我们使用的是简单的三层架构:
- 最上游是端,包括 PC、H5 和 App。
- 中间是 Web 应用。
- 下面是数据存储。
这样的架构能够适应 58 速运早期“抢时间”这一特点的快速发展模式,同时也能够支撑产品的快速迭代。
比如 58 速运能够在接到请求之后的 5 分钟内开车过来,将您的一个家具搬到某处。
在业务上,我们与滴滴的相同之处是:“同城、短途、及时性”;而区别则是:滴滴“带人”、我们“拉货”。
我们当前的业务主要分为三大块:
- 2 C,如:帮助大家搬家,不过客频次比较低,不属于我们主要的订单来源。
- 2 小 B,如:帮助卖五金、建材、卫浴等小商户每天把货物送到客户家里,所以频次比较高。
- 2 大 B,如:帮助 OFO 之类的企业客户每天将共享单车从仓库里运到各个地点。
所以总体来说,我们采用的是一般创业型公司最常见的架构,并将业务垂直地切分为三块。
包括:搬家的站点(Web);为小 B 叫“货的”的站点;为大 B“优配”的站点。在最底下则是统一的数据库存储。
随着业务的持续发展,数据量的慢慢上升,我们在之后的两、三年碰到了耦合的问题。
俗话说:历史总是惊人的相似,大家可以结合我下面的介绍,看看是否也遇到过此类问题?
微服务之前,系统中存在的耦合问题
为啥代码会 Copy 来 Copy 去?
最早期我们并没有小 B 类和大 B 类,而只有一个“货的”的系统和站点。所有用户都是统一的,并未做任何类型上的垂直切分,全部的请求也都通过“货的”的数据访问层,去访问底层数据。
接着,我们发现 C 类的客频次比较低,因此逐渐增加了“货的”业务、“优配”业务、“货的”的站点、“货的”的数据访问、“优配”的站点、“优配”的数据访问等。
可见,业务就这么一块、一块长出来了。但是代码可不是真正一行、一行写出来的。
在早期组织架构中,我们只有 5 个人负责“货的”的前端、后端,直至运维的全部。
后来我们增加了 3 个人负责“优配”业务,又增加了 10 个人从事“货的”业务。
可见,早期为了提高效率,几个人就这么粗犷地把研发到测试全干了。而后期就算有业务的新增,我们同样需要用到之前业务中对于用户数据的“增、删、查、改”。
而此时,我们的团队并不会从头将代码重写一遍,而是从同事那里将以前现成的代码复制、粘贴过来,再结合自己的业务特性稍作修改,并保持大部分代码的一致。
众所周知,代码复制会存在许多潜在的问题。因此在同一个模块、以及同一个工程里,我们不允许通过复制、粘贴而产生重复代码的函数;而在跨工程、跨业务、跨系统时,代码复制同样是被禁止的。
因为,如果原来的那套代码出现了问题,或是在用户数据表需要升级的时候,我们会面临许多地方需要修改的痛点。这正是跨系统、跨业务所带来的耦合问题。
从架构层面来说,通过对服务层进行抽象,能够缓解由于业务日趋复杂和重复代码的日益增多所带来的各种隐患。
因此,我们将用于访问“搬家”、“货的”、“优配”的用户数据的那部分代码抽象出来,变成一个通用的 user-service。
就像调用本地函数那样,业务方通过一行代码,传递一个 UID 过去,以获得 UID 的实例。
而具体如何拼装 SQL 语句,则被 DAO 层放到了 user-service 的微服务中,从而向上游屏蔽了底层的 SQL 拼装过程。
在抽象 Service 的过程中,我们所遵循的原则是:公共的部分下沉,而个性化的部分则由每个业务线来承担。
我们籍此减少了由于代码的反复拷贝所导致的耦合问题。可见,微服务是一种对于创业性公司业务增长的潜在解决方案。
为啥总是被迫联动升级?
随着我们数据量和访问量的上涨,系统的不同部分难免会出现不同的问题,最明显的就是:读取吞吐量的增大。
对于创业性公司的绝大部分业务场景来说,最先出现的都是由于读多写少所带来的数据库瓶颈问题。
所以一般来说我们不用去修改代码,而直接将数据库做出集群,以主从同步、和从多个服务器上读取数据的方式来提升读的性能。
同时我们也可以增加缓存,以降低数据库和磁盘 I/O 的压力。这都是常见的优化手段。
在增加了缓存之后,你会发现读取数据的流程和访问数据的代码也会相继发生了变化。
即:从直接访问数据库变成了先访问缓存,如果在缓存里命中、则直接返回;如果未命中、再访问读库、将数据取出后放入缓存中。
与此同时,数据的写入也会发生类似的变化。即:从直接操作写入数据库变成了需要考虑缓存的一致性,你必须得把缓存淘汰掉,才能修改数据库内容。
由于速运有着多块垂直的业务和不同的用户分类,因此引入缓存的复杂性会扩散到整个业务线上。
例如:由于用户的访问量巨大,我们增加了缓存,那么各个产品系统,包括“搬家”、“货的”、“优配”等流程就需要做相应的升级。
而其中“优配”的负责人会觉得:“是因为底层的复杂性扩散到我这里,我是被迫进行技术改进和升级的。”
那么随着数据量的增大,我们通过综合运用上述方法,采取了水平切分的方式来优化整体架构的性能。
例如:我们会将单个用户库或用户表转化为多个实例、多个库、多个表,以降低单实例、单库、单表的数据量,从而提升整体的容量。这也是互联网架构中十分常见的技术优化手段。
在过去单库的模式下,你只需要将 SQL 语句发往该数据库便可;而变成多个库之后,则会涉及到集函数、求最大/最小、Join 等方面。
由于数据库被水平切分,业务侧的代码需要做相应的改动。而当你有多个上游的时候,你会发现底层的复杂性会迅速扩散到所有的上游业务方那里。
上述提到的上游业务方所必须关注的缓存复杂性和切分复杂性,只是两个最典型的例子。
我们 58 同城还曾出现过:底层的存储引擎由 MySQL 变更为 MongoDB 的情况。
这些底层资源的耦合和复杂性的变化,都值得上游的所有业务方予以关注。
由此可见,服务化可以让上述问题得到缓解。因为,它只需要一个团队去关注底层的复杂性。
如上图所示在升级之后,所有的业务侧通过 RPC 就像调用本地函数一样去获取远端的数据,只要传一个 UID 过去便能获取一个用户的实体。
具体这些数据是放在哪个分库中(是放在缓存中、MySQL 中、还是 MongoDB 中),只需被服务层所关注。
而当底层需要升级的时候,所有的调用方,乃至所有的业务线都不会被牵动,我们只需对服务进行升级。可见,通过服务化,我们很好地解决了底层复杂性的耦合问题。
兄弟部分上线,为啥我们挂了?
在服务化之前,多个业务线会同时访问同一份数据,以前面的用户数据为例。
虽然我们的每个业务线都能够通过由 DAO 拼装的 SQL 语句去访问同一个数据层(当然也有些公司甚至都没有 DAO 层,而直接拼装 SQL 语句去访问数据库),但是每个业务线上工程师的能力是不一样的。
较资深的工程师在拼装 SQL 的过程中,会考虑到索引以及优化等问题;但是一些经验欠佳的工程师在写下一行 DAO 代码的时候,可能不曾想到它所被转化的 SQL 语句。
还可能因为没有命中索引,而导致数据库的全盘扫描,进而出现 CPU 的利用率达到百分之百的问题。
过去,我们“搬家”的业务线曾写了一个非常低效的 SQL 语句并发布到了线上。
它直接导致了整个数据库实例的 CPU 利用率高达百分之百,进而影响到了“货的”和“优配”。
而由于“搬家”的订单量远小于“货的”和“优配”的订单量,那么“货的”一旦访问订单的时候,就会发现系统是访问不了的。
这就造成了:“搬家”的上线却导致“货的”“挂掉”了的局面。究其原因,正是因为该架构中 SQL 语句的质量没有得到很好的控制。
另外,我们也需要遵从 SQL、Java 等方面的编程规范。我在负责 DBA 部门的时候,就曾要求:无论什么规范,都必须限定在十条以内,以合适一张 A4 纸单面打印出来。
在做了服务化之后,服务层应能够向上游业务提供一些相对比较通用的 RPC 访问,我们籍此可以通过服务层来控制 SQL 的质量。
这里同样以用户数据的“增、删、查、改”为例,在用户侧访问时,如果你传来用户名/密码,我就回传 UID;如果你传来一个 UID,我就给你一个用户的实例。
可见,这些接口都是非常有限且通用的。它们对于数据库的访问,都被控制在 Service 上,而非用户层面。所以我总结出来服务化具有如下原则:
数据库私有
任何上游不得绕过 Service 去访问底层数据库。业务层只能调用接口,即 SQL 由服务所决定,这一点很重要。
对上游提供有限且通用的接口
许多公司虽然做了服务化,但是服务层仍然有许多个性化的、与业务紧密相关的接口,这就没有达到服务化的目的。
例如:我们曾经在 user-service 里,有着大量与“搬家”、“货的”、“优配”相关的业务代码,一旦上游出现新的需求,他就提交给服务层去修改。
这样的话,user-service 实际上实现的是各种个性化的需求,由于这些接口的复用性低,因此不但会导致其代码的混乱,还会造成研发的瓶颈。可见,服务化只应该提供有限的通用接口。
服务侧要保证无限的性能
我们通过水平扩展、加缓存、分表等方式去解决各种并发量、吞吐量、和数据量的问题,从而保证了上游侧不必关心各种操作的实现细节。这就是服务维护者对外的一种服务承诺。
业务一旦出了问题只会影响到自己;如果服务出现了故障,那么就会有深远的影响,甚至会导致用户无法登录。
可见,诸如用户 Service、订单 Service、支付 Service、商家 Service,都必须具有良好的稳定性。
我们曾经在“同城”做过的一个实践是:将公司最基础的 Service 放置在架构部,由资深的工程师去做维护。
数据库拆分真的容易?
在最早期,由于 58 速运的数据量较小,我们只用一个库将所有表格包含其中。
这些表中既有如用户表这样的公共表格,也有一些业务个性化的表格,例如与“搬家”相关的一些用户信息。
公共表以 UID 为 Key 放置用户公布的属性;个性化表同样以 UID 为 Key,包括“搬家”用户个性化属性。那么,“搬家”的某些业务场景可能会同时提取公共的和个性化的数据。
由于只有一个库、一个实例,我们通过简单代码直接根据相同的 UID、运用 Join 去操作两张表,便可取出所有需要的数据。即使用到对于 UID 的索引,也不会有多次的交互,或出现性能的问题。
当然,这些都是基于两张表必须在同一个实例中的前提条件。同理,我们的“货的”、“优配”也是这么各自构建的。
另外,除了 Join,还有各种子查询、自制定函数、视图、触发器,都可能出现耦合在一个实例的情况。因此,我们很难将这种结构拆成多个实例。
那么当业务越来越复杂、数据量越来越大、数据库里的数据表越来越多时,我们势必要消除数据库的耦合,通过微服务架构的改造来拆分出多个实例。
如图所示,最上方是原始的耦合,我们在下面抽象出来共性的数据,包括 user-service 和 db-user(一个单独的实例)。
对于个性化的数据,我们也要拆到个性化的库里。如果你要进一步拆分的话,我们还能对共性的数据以及个性的数据分别抽象成 Service。
如图所示,“搬家”、“货的”、“优配”都分别有自己的 Service,和各自的数据库,从而实现了将业务整体数据拆到了多个单实例中。
我们的拆分目标是:实现数据请求需要根据 UID 访问 RPC 接口,并基于 user-service 先拿到共性数据。
如果你只是抽象了数据库,那么需要用 UID 去拼装 SQL 以拿个性的数据;如果你也抽象了业务 Service,那么就通过 UID 自己做逻辑拼装,产生完整的 SQL 语句,去访问业务 Service 的接口,从而得到业务个性化的数据。
这是一个循序渐进的过程,我们耗时三个季度,对站点应用层的代码做了大量的修改工作。
完成之后,我们实现了:根据 DBA 新增的设备台数和新的实例,将数据拆出来并迁移过去。
由上可见,两层变三层的架构给我们带来了四点好处:
- 加强了复用性
- 屏蔽了复杂性
- 保证了 SQL 质量
- 确保了扩展性
而且调用方不再需要关注 JDBC、DAO 和缓存,只需传送 UID 便可。
微服务架构,存在什么问题?
众所周知,各种技术大会一般都只讲服务化和微服务的好处,几乎不会提及坑点。
而大家也不要盲目地评判诸如 Dubbo 等微服务框架的优劣,更不要以为引入了 RPC 框架,就实现了服务化。
我们通过亲自实践,在经历了改造、消除了耦合、演进了架构的过程中,也遇到过如下的问题:
微服务会带来系统复杂性的上升
即:原来由数据库单点做缓存,改造后会增加多个服务层。
层次依赖关系会变得非常复杂
即:原来是 Nginx/站点/数据库的模式,改造后引入了多个相互依赖的服务,包括数据库与缓存。
而且服务还可能会再次调用其他的服务,例如:我们的“同城”,它在业务上就像一个包含了各种帖子的论坛,一般由商业置顶推荐部分、付费部分、中间自然搜索部分、下面人工部分、以及右侧的个人中心所组成。
这些数据的展示,需要先访问商业服务进行搜索、获得搜索数据后,再推荐服务,以及调用个性化的数据,最后拼装成一个列表页面。
这些代码在各个业务线上都有重复。而如果商业的结构需要升级,则所有的业务线接口都予以跟进;如果推荐部分出现了 Bug,那么所有都要跟着修改。
因此我们把相同的公共部分抽象为通用列表的服务,由它来统一调用底层的商业服务、自然搜索服务、推进服务和个人服务。
随着业务逻辑的日趋复杂,我们的服务层次也会增多,而服务的抽象和相互之间的依赖关系也势必日渐复杂。
监控和运维部署也会变得复杂
例如:在一个站点上集群了三个节点的时候,我们在早期并没有专门地去做运维,而是首先 SSH 到第一台→wget 一个 war 包→解压→restart。然后同法炮制第二台、第三台。
那么当站点有十个以上时,运维就不能这么做了。因此从长远来看,我们需要开发自动化的运维脚本和运维平台。
那么在引入服务化之后,随着服务与集群数量的增加,运维部署与监控的工作量也势必会有所增加。
定位问题更麻烦
例如:当用户反馈登录缓慢时,负责 Web 登录的人员通过排查发现是列表服务的问题,就转给其列表服务人员。
列表服务的人员经查发现是调用不出用户中心了→则由负责用户中心的工程师进一步调查→他们上升到 DBA 那里→DBA 通过运维人员才发现是阿里云上的某个节点出了问题。
最终认定问题不大,只需重启或摘除掉该节点,以及修改网络配置便可恢复。可见这样的定位过程是极其复杂的。
综上所述,微服务也会给我们带来一些潜在问题,因此大家要事先考虑周全。
58 速运的微服务实践
我们通过实践形成了一套技术体系,从而更快、更好地支持了自己的微服务架构:
统一的服务框架
我的建议是:要在一开始就定下整体统一的基础体系,通过统一语言、统一框架,来减少重复开发。
例如 58 同城很早就统一了自研的框架,尽管初期并不太好用,但是随着时间的推移,它被慢慢地改善且好用起来。
统一数据访问层
如果有的团队用 JDBC,有的用 DAO,这样重复的成本会很高,因此一定要事先达成共识。
配置中心
早期各个 user-service 的 IP 地址都被写在配置文件里,那么一旦服务需要扩容出一个节点,就需要找到所有调用它的上游调用方,告知 IP 地址的变更,调用方再各自经历复杂的修改,并配以必要的重启。
而如果我们使用的是配置中心的话,则可以通过简单配置,以平台发通知的方式,告知 IP 的变更,进而所有调用方的流量都会被迁移到新的节点之上。
服务治理
包括:服务发现与限流等一系列的问题。例如:某个上游的调用方写了一个带有 Bug 的死循环,导致将下游所有的调用次数都占满了。
那么我们可以运用服务质量的治理,根据调用方的峰值来进行配额和限流。
如此,就算出现了死循环,它只会把自己的配额用光,而不影响到其他的业务线。
可见服务质量的管理对于服务本身的快速扩/缩容,以及遇到问题时的降级,都是非常有用的。
统一监控
为了实现统一的服务框架和数据访问层,我们可以在框架层的请求出入口、在 DAO 的层面上、访问数据库的前/后、访问缓存、以及访问 Redis 的 MemoryCacheClient 时简单包装一层。
从而 hook 这些节点,快速地监控到所有的接口、数据库的访问、缓存访问的时间。可见在框架层面上,所有的接口都能够被统一监控到。
统一调用链分析
由于微服务化之后,层次关系变得复杂,因此我们需要具有一个调用关系的视图。
如果出现某个请求的超时,我们就能迅速定位到是网络、是数据库、还是节点的问题。
自动化运维平台
通过调节服务的上限与扩容等操作,让服务化给技术体系带来更大的便利。
总结
微服务解决了:代码拷贝的耦合,底层复杂性扩散的耦合,SQL 质量不可控,以及 DB 实例无法扩容的耦合问题。
同时,微服务带来的问题有:系统复杂性的上升,层次间依赖关系变得复杂,运维、部署更麻烦,监控变得更复杂,定位问题也更麻烦等。
因此服务化并不是简单引入一个RPC框架,而是需要一系列的技术体系来做支撑。
我们需要通过建立该技术体系,以解决如下可能面对的问题:
- 统一服务框架和数据访问层(包括:数据库的统一访问、缓存、Redis 的 MemoryCache 等)
- 配置中心和服务治理
- 统一的监控
- 调用链
- 自动化运维平台
互联网架构技术专家,“架构师之路”公众号作者。曾任百度高级工程师,58同城高级架构师,58 同城技术委员会主席。2015 年调至 58 到家任高级总监,技术委员会主席,负责基础架构,技术平台,运维安全,信息系统等后端技术体系搭建。2017 年调至 58 速运任 CTO,负责 58 速运技术体系的搭建。
【51CTO原创稿件,合作站点转载请注明原文作者和出处为51CTO.com】