微服务架构的技术体系、社区目前已经越来越成熟。在最初系统架构的搭建,或者当现有架构已到达瓶颈需要进行架构演进时,很多架构师、运维工程师会考虑是否需要搭建微服务架构体系。虽然很多文章都说微服务架构是复杂的、会带来很多分布式的问题,但只要我们了解这些问题,并找到解法,就会有种拨开云雾的感觉。


微服务架构也不是完美的,世上没有完美的架构,微服务架构也是随着业务、团队成长而不断演进的。最开始可能就几个、十几个微服务,每个服务是分库的,通过 API Gateway 并行进行服务数据合并、转发。随着业务扩大、不断地加入搜索引擎、缓存技术、分布式消息队列、数据存储层的数据复制、分区、分表等。


本课程会一一解开微服务架构下分布式场景的问题,以及通过对于一些分布式技术的原理、模型和算法的介绍,来帮助想要实施微服务架构的工程师们知其然并知其所以然。并且,本课程通过对分布式问题的体系化梳理,结合一些方案的对比选型,可以让工程师们一览微服务的知识图谱。


注:为了方便初学者理解微服务实践,以及掌握怎样在微服务中使用 DDD(Domain-Driven Design)思想,在本课程第 05 课中讲解了 Demo 示例,该示例是基于 Spring Boot、Spring Cloud Eureka 技术写的,Microservice 代码详见这里,Gateway 代码详见这里。


专家推荐

近年来随着互联网的快速发展,尤其是移动互联网以及云计算的迅猛发展,对于软件交付与迭代速度和效率的要求在不断提高。微服务架构凭借其简单清晰、灵活可扩展、独立部署等优势,越来越成为了分布式架构中的主流。相关的书籍和课程也层出不穷,但更多还是集中在基本理论介绍和一个简单的示例上。本系列课程内容融合了作者多年的实践经验,将微服务架构下的一些经典的分布式问题和场景逐一展开,结合最新的技术潮流,理论结合实际,深入剖析讲解,并且给出了很多对于具体实践选型非常有益的建议。可以说,该课程内容融合了作者从阿里巴巴到创业公司这一路走来所积累的精华,是微服务及分布式领域难得的佳作。


——阿里巴巴技术专家,荣博


作者介绍

李静瑶,2011 年毕业于中南大学(校优秀毕业生、优秀学生干部),毕业后入职阿里巴巴集团,在职期间主要负责淘宝网营销产品线的研发工作,曾担任试用中心产品线 PM。2015 年开始,在终点网络科技公司担任后端架构师,以及参与 Android、iOS 等客户端研发。现就职于赤金信息科技有限公司,担任 CTO 职位。从零搭建基于 Docker 容器技术的微服务分布式企业集群,深度的 DDD 思想践行者。个人博客:https://blog.csdn.net/lijingyao8206/。


课程内容

导读:微服务架构下的分布式概览

微服务架构的演变

微服务是一种服务间松耦合的、每个服务之间高度自治并且使用轻量级协议进行通信的可持续集成部署的分布式架构体系。这一句包含了微服务的特点,微服务架构和其他架构有什么区别?以下对比一些常见的架构。


单体架构

单体架构是最简单的软件架构,常用于传统的应用软件开发以及传统 Web 应用。传统 Web 应用,一般是将所有功能模块都打包(jar、war)在一个 Web 容器(JBoss、Tomcat)中部署、运行。随着业务复杂度增加、技术团队规模扩大,在一个单体应用中维护代码,会降低开发效率,即使是处理一个小需求,也需要将所有机器上的应用全部部署一遍,增加了运维的复杂度。


SOA 架构

当某一天使用单体架构发现很难推进需求的开发、以及日积月累的技术债时,很多企业会开始做单体服务的拆分,拆分的方式一般有水平拆分和垂直拆分。垂直拆分是把一个应用拆成松耦合的多个独立的应用,让应用可以独立部署,有独立的团队进行维护;水平拆分是把一些通用的,会被很多上层服务调用的模块独立拆分出去,形成一个共享的基础服务,这样拆分可以对一些性能瓶颈的应用进行单独的优化和运维管理,也在一定程度上防止了垂直拆分的重复造轮子。


SOA 也叫面向服务的架构,从单体服务到 SOA 的演进,需要结合水平拆分及垂直拆分。SOA 强调用统一的协议进行服务间的通信,服务间运行在彼此独立的硬件平台但是需通过统一的协议接口相互协作,也即将应用系统服务化。举个易懂的例子,单体服务如果相当于一个快餐店,所有的服务员职责都是一样的,又要负责收银结算,又要负责做汉堡,又要负责端盘子,又要负责打扫,服务员之间不需要有交流,用户来了后,服务员从前到后负责到底。SOA 相当于让服务员有职责分工,收银员负责收银,厨师负责做汉堡,×××阿姨负责打扫等,所有服务员需要用同一种语言交流,方便工作协调。


微服务和 SOA

微服务也是一种服务化,不过其和 SOA 架构的服务化概念也是有区别的,可以从以下几个关键字来理解:


松耦合:每个微服务内部都可以使用 DDD(领域驱动设计)的思想进行设计领域模型,服务间尽量减少同步的调用,多使用消息的方式让服务间的领域事件来进行解耦。

轻量级协议:Dubbo 是 SOA 的开源的标准实现之一,类似的还有像 gRPC、Thrift 等。微服务更倾向于使用 Restful 风格的 API,轻量级的协议可以很好地支持跨语言开发的服务,可能有的微服务用 Java 语言实现,有的用 Go 语言,有的用 C++,但所有的语言都可以支持 Http 协议通信,所有的开发人员都能理解 Restful 风格 API 的含义。

高度自治和持续集成:从底层的角度来说,SOA 更加倾向于基于虚拟机或者服务器的部署,每个应用都部署在不同的机器上,一般持续集成工具更多是由运维团队写一些 Shell 脚本以及提供基于共同协议(比如 Dubbo 管理页面)的开发部署页面。微服务可以很好得和容器技术结合,容器技术比微服务出现得晚,但是容器技术的出现让微服务的实施更加简便,目前 Docker 已经成为很多微服务实践的基础容器。因为容器的特色,所以一台机器上可以部署几十个、几百个不同的微服务。如果某个微服务流量压力比其他微服务大,可以在不增加机器的情况下,在一台机器上多分配一些该微服务的容器实例。同时,因为 Docker 的容器编排社区日渐成熟,类似 Mesos、Kubernetes 及 Docker 官方提供的 Swarm 都可以作为持续集成部署的技术选择。

其实从架构的演进的角度来看,整体的演进都是朝着越来越轻量级、越来越灵活的应用方向发展,甚至到近两年日渐成熟起来的 Serverless(无服务)架构。从单体服务到分层的服务,再到面向服务、再到微服务甚至无服务,对于架构的挑战是越来越大。


微服务架构和分布式

微服务架构属于分布式系统吗?答案是肯定的。微服务和 SOA 都是典型的分布式架构,只不过微服务的部署粒度更细,服务扩展更灵活。


理解微服务中的分布式

怎样理解微服务中的分布式?举个招聘时某同学来面试的例子。A 同学说,目前所在公司在做从单应用到微服务架构迁移,已经差不多完成了。提到微服务感觉就有话题聊了,于是便问“是否可以简单描述下服务拆分后的部署结构、底层存储的拆分、迁移方案?”。于是 A 同学说,只是做了代码工程结构的拆分,还是原来的部署方式,数据库还是那个库,所有的微服务都用一个库,分布式事务处理方式是“避免”,尽量都同步调用……于是我就跟这位同学友好地微笑说再见了。


微服务的分布式不仅仅是容器应用层面的分布式,其为了高度自治,底层的存储体系也应该互相独立,并且也不是所有的微服务都需要持久化的存储服务。一个“手机验证码”微服务可能底层存储只用一个 Redis;一个“营销活动搭建页面”微服务可能底层存储只需要一个 MongoDB。


微服务中的分布式场景除了服务本身需要有服务发现、负载均衡,微服务依赖的底层存储也会有分布式的场景:为了高可用性和性能需要处理数据库的复制、分区,并且在存储的分库情况下,微服务需要能保证分布式事务的一致性。


课程背景

微服务架构的技术体系、社区目前已经越来越成熟,所以在初期选择使用或者企业技术体系转型微服务的时候,需要了解微服务架构中的分布式的问题:


在所有服务都是更小单元的部署结构时,一个请求需要调动更多的服务资源,怎样获得更好的性能?

当业务规模增大,需要有地理分布不同的微服务集群时,其底层的数据存储集群是多数据中心还是单数据集群?

数据存储如何进行数据复制?

业务数据达到大数据量时怎样进行数据的分区?

分布式事务怎样保证一致性?

不同程度的一致性有什么差别?

基于容器技术的服务发现怎么处理?

应该用哪些 RPC 技术,用哪些分布式消息队列来完成服务通信和解耦?

那么多的分布式技术框架、算法、服务应该选哪个才适合企业的业务场景?

本课程从微服务不得不面对和解决的分布式问题出发,包含分布式技术的一系列理论以及架构模型、算法的介绍,同时结合技术选型和实践应用,提供一系列解决方案的梳理。相信阅读完整个课程,你会对微服务的分布式问题有个系统地理解。本课程会对微服务的分布式场景问题一一击破,为你提供解决思路。


课程内容

本课程示例代码地址如下:


Microservice

API-gateway

分布式系统的问题

引出分布式系统的可能问题:节点故障、网络延迟,结合错误检测的可行方案进行介绍;

分布式中的时间和顺序的问题,以及标量时钟和向量时钟的实现。

分布式数据存储

分布式数据存储的技术选型、关系型数据库以及一些流行的 NoSQL 技术介绍(MongoDB、Redis、Neo4j 及 Cassandra 等);

分布式存储技术使用的数据结构,了解底层数据存储原理(HashTable、SSTable、LSMTree、BTree 等);

各个存储方案使用的场景以及对比。

数据复制

对于大规模存储集群,需要进行数据库的复制、排除单点故障;

数据复制的模型和实现以及几种复制日志的实现方式;

主备同步、主主复制、多数据中心的数据复制方案;

数据复制中的读写一致性问题以及写冲突问题的解决;

介绍以 MySQL 为例延伸集群数据复制方案。

数据分区

当单个领域模型维度的数据已经到一定规模时,需要进行数据分区,减轻单库压力。数据分区和分表又有哪些不同?数据分区可以如何实现?

以 MySQL 的分区策略为例介绍不同分区策略的实现。

数据分区后,请求的路由有哪些解决方案?会展开介绍不同的方案又有什么差别。

服务发现和服务通信

基于容器技术的微服务体系,怎样选择服务发现、负载均衡的技术实现?不同的服务发现的技术有什么区别,如何选型?

为了达到松耦合的目的以及基于 DDD 思想,微服务之间减少服务调用可以通过哪些技术实现?API Gateway 可以用哪些技术框架实现?远程调用可以有哪些技术框架?怎样提高同步通信的性能?

分布式的消息队列都有哪些开源、商业实现?应该怎样选择适合的消息队列?

使用 DDD 思想应该如何应对服务通信,如何在实践中应用 DDD?

分布式存储集群的事务

理解分布式中的事务以及本地事务的基础概念;

分布式存储的隔离级别以及各个 DB 支持的隔离方案的实现原理;

以 MySQL InnoDB 中的 MVCC 为例看并发控制的在 MySQL 的实现,学习存储系统对于分布式事务的实现思想。

分布式一致性

了解分布式系统的一致性有哪些问题以及一致性的几种实现程度的模型:线性一致性(强一致性)、顺序一致性及因果一致性、最终一致性;

分布式一致性相关的理论 CAP(CA、CP、AP 的相关算法)的介绍以及适合用于哪些实践;

介绍 FLP 不可能结果,以及 BASE 理论。

分布式事务实践

了解微服务中分布式事务的问题;

介绍强一致性的实践:二阶段、三阶段。2PC、3PC 的优缺点和限制,XA 协议的介绍和实践方案,以及最终一致性实践:TCC 模型和实践方案;

分布式锁的实现模型和实践方案;

基于微服务下的分布式事务实践案例分析。

共识问题

了解为什么分布式场景下有共识问题;

介绍共识算法和全局消息广播的实现,公式算法的基础:leader 选举和 quorum 算法,以及一些已实现的算法的介绍和对比:VSR、Raft、Paxos、ZAB;

共识算法在微服务体系的应用场景介绍:服务发现、一致性 kv 存储(Etcd、Zk)以及技术的选型如何权衡一致性的追求和性能。

架构设计

了解了很多分布式的问题和解决方案之后,回归微服务架构模型、技术选型、再回顾下微服务的弊端和特点;

微服务体系的架构要素分析:安全、伸缩性、性能、可用性、扩展性;

结合团队、业务场景的 DDD 实践和总结。

课程寄语

如果你是一位开发工程师,相信阅读完本系列课程,将会了解很多分布式系统的理论知识,同时也会理解一些分布式存储、中间件技术的原理,对工作中的分布式架构会有体系化的清晰认知。

如果你是一位架构师,本系列课程提供了对于分布式系统问题的全面梳理,以及一些技术背后的理论,结合实践和目前业界先进的方案,对于技术选型和架构搭建提供了参考。

点击了解更多《案例上手分布式微服务架构》


第01课:分布式系统的问题

前言

无论是 SOA 或者微服务架构,都是必须要面对和解决一些分布式场景下的问题。如果只是单服务、做个简单的主备,那么编程则会成为一件简单幸福的事,只要没有 bug,一切都会按照你的预期进行。然而在分布式系统中,如果想当然的去按照单服务思想编程和架构,那可能会收获很多意想不到的“惊喜”:网络延迟导致的重复提交、数据不一致、部分节点挂掉但是任务处理了一半等。在分布式系统环境下编程和在单机器系统上写软件最大的差别就是,分布式环境下会有很多很“诡异”的方式出错,所以我们需要理解哪些是不能依靠的,以及如何处理分布式系统的各种问题。


理想和现实

微服务架构跟 SOA 一样,也是服务化的思想。在微服务中,我们倾向于使用 RESTful 风格的接口进行通信,使用 Docker 来管理服务实例。我们的理想是希望分布式系统能像在单个机器中运行一样,就像客户端应用,再坏的情况,用户只要一键重启就可以重新恢复,然而现实是我们必须面对分布式环境下的由网络延迟、节点崩溃等导致的各种突发情况。


在决定使用分布式系统,或者微服务架构的时候,往往是因为我们希望获得更好的伸缩性、更好的性能、高可用性(容错)。虽然分布式系统环境更加复杂,但只要能了解分布式系统的问题以及找到适合自己应用场景的方案,便能更接近理想的开发环境,同时也能获得伸缩性、性能、可用性。


分布式系统的可能问题

分布式系统从结构上来看,是由多台机器节点,以及保证节点间通信的网络组成,所以需要关注节点、网络的特征。


(1)部分失败


在分布式环境下,有可能是节点挂了,或者是网络断了,如下图:




如果系统中的某个节点挂掉了,但是其他节点可以正常提供服务,这种部分失败,不像单台机器或者本地服务那样好处理。单机的多线程对象可以通过机器的资源进行协调和同步,以及决定如何进行错误恢复。但在分布式环境下,没有一个可以来进行协调同步、资源分配以及进行故障恢复的节点,部分失败一般是无法预测的,有时甚至无法知道请求任务是否有被成功处理。


所以在开发需要进行网络通讯的接口时(RPC 或者异步消息),需要考虑到部分失败,让整个系统接受部分失败并做好容错机制,比如在网络传输失败时要能在服务层处理好,并且给用户一个好的反馈。


(2)网络延迟


网络是机器间通信的唯一路径,但这条唯一路径并不是可靠的,而且分布式系统中一定会存在网络延迟,网络延迟会影响系统对于“超时时间”、“心跳机制”的判断。如果是使用异步的系统模型,还会有各种环节可能出错:可能请求没有成功发出去、可能远程节点收到请求但是处理请求的进程突然挂掉、可能请求已经处理了但是在 Response,可能在网络上传输失败(如数据包丢失)或者延迟,而且网络延迟一般无法辨别。


即使是 TCP 能够建立可靠的连接,不丢失数据并且按照顺序传输,但是也处理不了网络延迟。对于网络延迟敏感的应用,使用 UDP 会更好,UDP 不保证可靠传输,也不会丢失重发,但是可以避免一些网络延迟,适合处理音频和视频的应用。


(3)没有共享内存、锁、时钟


分布式系统的节点间没有共享的内存,不应理所当然认为本地对象和远程对象是同一个对象。分布式系统也不像单机器的情况,可以共享同一个 CPU 的信号量以及并发操作的控制;也没有共享的物理时钟,无法保证所有机器的时间是绝对一致的。时间的顺序是很重要的,夸张一点说,假如对于一个人来说,从一个时钟来看是 7 点起床、8 点出门,但可能因为不同时钟的时间不一致,从不同节点上来看可能是 7 点出门、8 点起床。


在分布式环境下开发,需要我们能够有意识地进行问题识别,以上只是举例了一部分场景和问题,不同的接口实现,会在分布式环境下有不同的性能、扩展性、可靠性的表现。下面会继续上述的问题进行探讨,如何实现一个更可靠的系统。


概念和处理模型

对于上述分布式系统中的一些问题,可以针对不同的特征做一些容错和处理,下面主要看一下错误检测以及时间和顺序的处理模型。在实际处理中,一般是综合多个方案以及应用的特点。


错误检测

对于部分失败,需要一分为二的看待。


节点的部分失败,可以通过增加错误检测的机制,自动检测问题节点。在实际的应用中,比如有通过 Load Balancer,自动排除问题节点,只将请求发送给有效的节点。对于需要有 Leader 选举的服务集群来说,可以引入实现 Leader 选举的算法,如果 Leader 节点挂掉了,其余节点能选举出新的 Leader。实现选举算法也属于共识问题,在后续文章中会再涉及到几种算法的实现和应用。


网络问题:由于网络的不确定性,比较难说一个节点是否真正的“在工作”(有可能是网络延迟导致的错误),通过添加一些反馈机制可以在一定程度确定节点是否正常运行,比如:


健康检查机制,一般是通过心跳检测来实现的,比如使用 Docker 的话,Consul、Eureka 都有健康检查机制,当发送心跳请求发现容器实例已经无法回应时,可以认为服务挂掉了,但是却很难确认在这个 Node/Container 中有多少数据被正确的处理了。

如果一个节点的某个进程挂了,但是整个节点还可以正常运行。在使用微服务体系中是比较常见的,一台机器上部署着很多容器实例,其中个容器实例(即相当于刚才描述挂掉的进程)挂掉了,可以有一个方式去通知其他容器来快速接管,而不用等待执行超时。比如 Consul 通过 Gossip 协议进行多播,关于 Consul,可以参考这篇 Docker 容器部署 Consul 集群 内容。在批处理系统中,HBase 也有故障转移机制。

在实际做错误检测处理时,除了需要 节点、容器 做出积极的反馈,还要有一定的重试机制。重试的实现可以基于网络传输协议,如使用 TCP 的 RTT;也可以在应用层实现,如 Kafka 的 at-least-once 的实现。基于 Docker 体系的应用,可以使用 SpringCloud 的 Retry,结合 Hytrix、Ribbon 等。对于需要给用户反馈的应用,不太建议使用过多重试,根据不同的场景进行判断,更多的时候需要应用做出积极的响应即可,比如用户的“个人中心页面”,当 User 微服务挂了,可以给个默认头像、默认昵称,然后正确展示其他信息,而不是反复请求 User 微服务。


时间和顺序

在分布式系统中,时间可以作为所有执行操作的顺序先后的判定标准,也可以作为一些算法的边界条件。在分布式系统中决定操作的顺序是很重要的,比如对于提供分布式存储服务的系统来说,Repeated Read 以及 Serializable 的隔离级别,需要确定事务的顺序,以及一些事件的因果关系等。


物理时钟

每个机器都有两个不同的时钟,一个是 time-of-day,即常用的关于当前的日期、时间的信息,例如,此时是 2018 年 6 月 23 日 23:08:00,在 Java 中可以用 System.currentTimeMillis() 获取;另一个是 Monotonic 时钟,代表着单调递增的时间,一般是测量时间间距,在 Java 中调用 System.nanoTime() 可以获得 Monotonic 时间,常常用于测量一个本地请求的返回时间,比如 Apache commons 中的 StopWatch 的实现。


在分布式环境中,一般不会使用 Monotonic,测量两台不同的机器的 Monotonic 的时间差是无意义的。


不同机器的 time-of-day 一般也不同,就算使用 NTP 同步所有机器时间,也会存在毫秒级的差,NTP 本身也允许存在前后 0.05% 的误差。如果需要同步所有机器的时间,还需要对所有机器时间值进行监控,如果一个机器的时间和其他的有很大差异,需要移除不一致的节点。因为能改变机器时间的因素比较多,比如无法判断是否有人登上某台机器改变了其本地时间。


虽然全局时钟很难实现,并且有一定的限制,但基于全局时钟的假设还是有一些实践上的应用。比如 Facebook Cassandra 使用 NTP 同步时间来实现 LWW(Last Write Win)。Cassandra 假设有一个全局时钟,并基于这个时钟的值,用最新的写入覆盖旧值。当然时钟上的最新不代表顺序的最新,LWW 区分不了实际顺序;另外还有如 Google Spanner 使用 GPS 和原子时钟进行时间同步,但节点之间还是会存在时间误差。


逻辑时钟

在分布式系统中,因为全局时钟很难实现,并且像 NTP 同步过程,也会受到网络传输时间的影响,一般不会使用刚才所述的全局同步时间,当然也肯定不能使用各个机器的本地时间。对于需要确认操作执行顺序的时候,不能简单依赖一个基于 time-of-day 的 timestamps,所以需要一个逻辑时钟,来标记一些事件顺序、操作顺序的序列号。常见的方式是给所有操作加上递增的计数器。


这种所有操作都添加一个全局唯一的序列号的方式,提供了一种全局顺序的保证,全局顺序也包含了因果顺序一致的概念。关于分布式一致性的概念和实现会在后续文章详细介绍,我们先将关注点回归到时间和顺序上。下面看两种典型的逻辑时钟实现。


(1)Lamport Timestamps


Lamport timestamps 是 Leslie Lamport 在 1978 年提出的一种逻辑时钟的实现方法。Lamport Timestamps 的算法实现,可以理解为基于每个节点的一对值(NodeId,Counter)的全局顺序的同步。在集群中的每个节点(Node)都有一个唯一标识,并且每个 Node 都持有一个本地的对于所有操作顺序的一个 Counter(计数器)。


Lamport 实现的核心思想就是把事件分成三类(节点内处理的事件、发送事件、接收事件):


如果一个节点处理一个事件,节点 counter +1。

如果是发送一个消息事件,则在消息中带上 counter 值。

如果是接收一个消息事件,则更新 counter = max(本地 counter,接收的消息中带的 counter) +1。

简单画了个示例如下图:




初始的 counter 都是 0,在 Node1 接收到请求,处理事件时 counter+1(C:1表示),并且再发送消息带上 C:1。


在 Node1 接受 ClientA 请求时,本地的 Counter=1 > ClientA 请求的值,所以处理事件时 max(1,0)+1=2(C:2),然后再发送消息,带上 Counter 值,ClientA 更新请求的最大 Counter 值 =2,并在下一次对 Node2 的事件发送时会带上这个值。


这种序列号的全局顺序的递增,需要每次 Client 请求持续跟踪 Node 返回的 Counter,并且再下一次请求时带上这个 Counter。lamport 维护了全局顺序,但是却不能更好的处理并发。在并发的情况下,因为网络延迟,可能导致先发生的事件被认为是后发生的事件。如图中红色的两个事件属于并发事件,虽然 ClientB 的事件先发出,但是因为延迟,所以在 Node 1 中会先处理 ClientA,也即在 Lamport 的算法中,认为 Node1(C:4) happens before Node1(C:5)。


Lamport Timestamps 还有另一种并发冲突事件:不同的 NodeId,但 Counter 值相同,这种冲突会通过 Node 的编号的比较进行并发处理。比如 Node2(C:10)、Node1(C:10) 是两个并发事件,则认为 Node2 的时间顺序值 > Node1 的序列值,也就认为 Node1(C:10) happens before Node2(C:10)。


所以可见,Lamport 时间戳是一种逻辑的时间戳,其可以表示全局的执行顺序,但是无法识别并发,以及因果顺序,并发情况无法很好地处理 偏序。


(2)Vector Clock


Vector Clock 又叫向量时钟,跟 Lamport Timestamps 比较类似,也是使用 SequenceNo 实现逻辑时钟,但是最主要的区别是向量时钟有因果关系,可以区分两个并发操作,是否一个操作依赖于另外一个。


Lamport Timestamps 通过不断把本地的 counter 更新成公共的 MaxCounter 来维持事件的全局顺序。Vector Clock 则各个节点维持自己本地的一个递增的 Counter,并且会多记录其他节点事件的 Counter。通过维护了一组 [NodeId,Counter] 值来记录事件的因果顺序,能更好得识别并发事件,也即,Vector Clock 的 [NodeId,Counter] 不仅记录了本地的,还记录了其他 Node 的 Counter 信息。


Vector Clock 的 [NodeId,Counter] 更新规则:


如果一个节点处理一个事件,节点本地的逻辑时钟的 counter +1。

当节点发送一个消息,需要包含所有本地逻辑时钟的一组 [NodeId,Counter] 记录值。

接受一个事件消息时, 更新本地逻辑时钟的这组 [NodeId,Counter] 值:

让这组 [NodeId,Counter] 值中每个值都是 max(本地 counter,接收的消息中的counter)。

本地逻辑时钟 counter+1。

如下图简单示意了 Vector Clock 的时间顺序记录:




三个 Node,初始 counter 都是 0。NodeB 在处理 NodeC 的请求时,记录了 NodeC 的 Counter=1,并且处理事件时,本地逻辑时钟的 counter=0+1,所以 NodeB 处理事件时更新了本地逻辑时钟为 [B:1,C:1]。在事件处理时,通过不断更新本地的这组 Counter,就可以根据一组 [NodeId,Counter] 值来确定请求的因果顺序了,比如对于 NodeB,第二个处理事件 [A:1,B:2,C:1] 早于第三个事件:[A:1,B:3,C:1]。


在数值冲突的时候,如图中红色箭头标记的。NodeC 的 [A:2,B:2,C:3] 和 NodeB[A:3,B:4,C:1]。C:3 > C:1、B:2 < B:4,种情况认为是没有因果关系,属于同时发生。


Vector Clock 可以通过各个节点的时间序列值的一组值,识别两个事件的先后顺序。Vector 提供了发现数据冲突的方式,但是具体怎样解决冲突需要由发现冲突的节点决定,比如可以将并发冲突抛给 Client 决定,或者用 Quorum-NRW 算法进行读取修复(Read Repair)。


Amazon Dynamo 就是通过 Vector Clock 来做并发检测的一个很好的分布式存储系统的例子。对于复制节点的数据冲突使用了 Quorum NRW 决议,以及读修复(Read Repair)处理最新更新数据的丢失,详细实现可以参考这篇论文 Dynamo: Amazon’s Highly Available Key-value Store ,Dynamo 是典型的高可用、可扩展的,提供弱一致性(最终一致性)保证的分布式 K-V 数据存储服务。后续文章再介绍 Quorums 算法时,也会再次提到。Vector Clock 在实际应用中,还会有一些问题需要处理,比如如果一个上千节点的集群,那么随着时间的推移,每个 Node 将需要记录大量 [NodeId,Counter] 数据。Dynamo 的解决方案是通过添加一个 timestamp 记录每个 Node 更新 [NodeId,Counter] 值的时间,以及一个设定好的阈值,比如说阈值是 10,那么本地只保存最新的 10 个 [NodeId,Counter] 组合数据。


小结

本文引出了一些分布式系统的常见问题以及一些基础的分布式系统模型概念,微服务的架构目前已经被更广泛得应用,但微服务面临的问题其实也都是经典的分布式场景的问题。本文在分布式系统的问题中,主要介绍了关于错误检测以及时间和顺序的处理模型。


关于时间和顺序的问题处理中,没有一个绝对最优的方案,Cassandra 使用了全局时钟以及 LWW 处理顺序判定;Dynamo 使用了 Vector clock 发现冲突,加上 Quorum 算法处理事件并发。这两个存储系统都有很多优秀的分布式系统设计和思想,在后续文章中会更详细的介绍数据复制、一致性、共识算法等。


参考资料

A Note on Distributed Computing

Distributed systems for fun and profit

Consul docker

Time, Clocks and the Ordering of Events in a Distributed System

Dynamo: Amazon’s Highly Available Key-value Store

点击了解更多《案例上手分布式微服务架构》


第02课:数据存储

前言

微服务架构下,很适合用 DDD(Domain-Drive Design)思维来设计各个微服务,使用领域驱动设计的理念,工程师们的关注点需要从 CRUD 思维中跳出来,更多关注通用语言的设计、实体以及值对象的设计。至于数据仓库,会有更多样化的选择。分布式系统中数据存储服务是基础,微服务的领域拆分、领域建模可以让数据存储方案的选择更具灵活性。


不一定所有的微服务都需要有一个底层的关系型数据库作为实体对象实例的存储。以一个简单的电商系统为例:“用户微服务”和“商品微服务”都分别需要关系型数据库存储结构化的关联数据。但比如有一个“关联推荐微服务“需要进行用户购买、加购物车、订单、浏览等多维度的数据整合,这个服务不能将其他所有订单,用户、商品等服务的数据冗余进来,这种场景可以考虑使用图形数据库。又比如有一个“验证码微服务”,存储手机验证码、或者一些类似各种促销活动发的活动码、口令等,这种简单的数据结构,而且读多写少,不需长期持久化的场景,可以只使用一个 K-V(键值对)数据库服务。


本文先简单介绍下适合微服务架构体系的一些分布式数据存储方案,然后深入介绍下这些存储服务的数据结构实现,知其然知其所以然。后续文章会继续介绍分布式数据存储的复制、分区。


数据存储类型介绍

不同的数据存储引擎有着不同的特征,也适合不同的微服务。在做最初的选型时,需要先根据对整体业务范围的判断,选择尽量普适于大多数微服务的存储。例如,初创型企业,需要综合考虑成本节约以及团队的知识掌握度等问题,MySQL 是比较常见的选择,电商类型的微服务应用更适合 InnoDB 引擎(事务、外键的支持、行锁的性能),虽然 InnoDB 的读性能会比 MyISAM 差,但是读场景有很多可以优化的方案,如搜索引擎、分布式缓存、本地缓存等。


下面会以不同场景为例,整理一部分常用的数据存储引擎,实际的企业应用中会针对不同场景、服务特征综合使用多种存储引擎。


关系型数据库

存储结构化数据,以及需要更多维度关联,需要给用户提供丰富的实时查询场景时,应该使用关系型数据库。从开源以及可部署高可用性集群的方面来看,MySQL 和 PostgreSQL 都是不错的选择。PostgreSQL 的历史更为悠久,两者都有很多大互联网公司如 Twitter、Facebook、Yahoo 等部署着大规模分布式存储集群,集群的复制、分区方案会在后续文章详细介绍。


NoSQL

NoSQL 即 Not Only SQL,其概念比关系型数据库更新,NoSQL 为数据的查询提供了更灵活、丰富的场景。下面简单列举了一些 NoSQL 数据库及其应用场景。工程师不一定需要掌握所有的 NoSQL 数据库的细节,对于不同的领域模型的设计,能有更多的灵感会更好。


KeyValue 存储

KeyValue 可以说是 NoSQL 中比较简单的一族,大多数操作只有 get()、put(),基础的数据格式也都是简单的 Key-Value。


目前比较流行的键值存储服务有 Redis 和 Memcached 以及上篇文中提到的 Dynamo。其中 Redis 有 Redis Cluster 提供了支持 Master 选举的高可用性集群。Dynamo 也有分布式高可用集群,基于 Gossip 协议的节点间故障检测,以及支持节点暂时、永久失效的故障恢复,这两者为了保证高可用以及性能,牺牲了强一致性的保证,但是都支持最终一致性。Memcached 提供了高性能的纯基于内存的 KV 存储,并且提供 CAS 操作来支持分布式一致性,但 Memcached 没有官方提供的内置集群方案,需要使用一些代理中间件,如 Magento 来部署集群。


在实际选择时,如果需要高速缓存的性能并且可以接受缓存不被命中的情况,以及可以接受 Memcached 服务实例重启后数据全部丢失,可以选择 Memcached。用 Memcached 做二级缓存来抗住一些高 QPS 的请求是很适合的,比如对于一些 Hot 商品的信息,可以放到 Memcached 中,缓解 DB 压力。


如果既需要有数据持久化的需求,也希望有好的缓存性能,并且会有一些全局排序、数据集合并等需求,可以考虑使用 Redis。Redis 除了支持 K-V 结构的数据,还支持 list、set、hash、zset 等数据结构,可以使用 Redis 的 SET key value 操作实现一些类似手机验证码的存储,对于需要按照 key 值排序的 kv 数据可以用 ZADD key score member。利用 Redis 的单线程以及持久化特性,还可以实现简单的分布式锁,具体可以参考笔者之前写的这篇 《基于 Redis 实现分布式锁实现》 文章。


文档型数据库

面向文档的数据库可以理解成 Value 是一个文档类型数据的 KV 存储,如果领域模型是个文件类型的数据、并且结构简单,可以使用文档型数据库,比较有代表性的有 MongoDB、CouchDB。MongoDB 相比可用性,更关注一致性,Value 存储格式是内置的 BSON 结构,CouchDB 支持内置 JSON 存储,通过 MVCC 实现最终一致性,但保证高可用性。


如果你需要的是一个高可用的多数据中心,或者需要 Master-Master,并且需要能承受数据节点下线的情况,可以考虑用 CouchDB。如果你需要一个高性能的,类似存储文档类型数据的 Cache 层,尤其写入更新比较多的场景,那就用 MongoDB 吧。另外,2018 年夏天可以期待下,MongoDB 官方宣布即将发布的 4.0 版本,支持跨副本集(Replica set)的 ACID 事务,4.2 版本将支持跨集群的事务,详情可以关注 MongoDB 的 Beta 计划。


图形数据库

在现实世界中,一个图形的构成主要有“点”和“边”,在图形数据库中也是一样,只不过点和边有了抽象的概念,“点”代表着一个实体、节点,“边”代表着关系。开源的 Neo4j 是可以支持大规模分布式集群的图形数据库。一般被广泛用于道路交通应用、SNS 应用等,Neo4j 提供了独特的查询语言 CypherQueryLanguage。


为了直观了解 Neo4j 的数据结构,可以看下这个示例(在运行 Neo4j 后,官方的内置数据示例),图中绿色节点代表“Person”实体,中间的有向的剪头连线就是代表节点之间的关系“Knows”。




通过以下 CQL 语句就可以查询所有 Knows、Mike 的节点以及关系:


MATCH p=()-[r:KNOWS]->(g) where g.name ='Mike' RETURN p LIMIT 25 



以上只是单个点和单维度关系的例子,在实际中 Person 实体间可能还存在 Follow、Like 等关系,如果想找到 Knows 并且 Like Mike,同时又被 Jim Follow 的 Person。在没有图形数据库的情况下,用关系型数据库虽然也可以查询各种关联数据,但这需要各种表 join、union,性能差而且需要写很多 SQL 代码,用 CQL 只要一行即可。


在 SpringBoot 工程中,使用 Springboot-data 项目,可以很简单地和 Neo4j 进行集成,官方示例可以直接 checkout 查看 java-spring-data-neo4j。


文档数据库一般都是很少有数据间的关联的,图形数据库就是为了让你快速查询一切你想要的关联。如果想更进一步了解 Neo4j,可以直接下载 Neo4j 桌面客户端,一键启动、然后在浏览器输入 http://localhost:7474/browser/ 就可以用起来了。


列族数据库

列族数据库一般都拥有大规模的分布式集群,可以用来做灵活的数据分析、处理数据报表,尤其适合写多读少的场景。列族和关系型数据库的差别,从应用角度来看,主要是列族没有 Schema 的概念,不像关系型数据库,需要建表的时候定义好每个列的字段名、字段类型、字段大小等。


列族数据库中目前比较广泛应用的有 Hbase,Hbase 是基于 Google BigTable 设计思想的开源版。BigTable 虽然没开源,但是其论文 Bigtable: A Distributed Storage System for Structured Data 提供了很多设布式列族 DB 的实现逻辑。另外 Facebook Cassandra 也是一个写性能很好的列族数据库,其参考了 Dynamo 的分布式设计以及 BigTable 的数据存储结构,支持最终一致性,适合跨地域的多数据中心的分布式存储。不过 Cassandra 中文社区相对薄弱,国内还是 Hbase 的集群更为广泛被部署。


存储服务的数据结构

在了解了一些分布式数据存储的产品之后,为了能更深地理解,下面会对分布式存储引擎的一些常用数据结构做进一步介绍。一台计算机,可以作为数据存储的地方就是内存、磁盘。分布式的数据存储就是将各个计算机(Node)的内存和磁盘结合起来,不同类型的存储服务使用的核心数据结构也会不同。


哈希表

哈希表是一种比较简单 K-V 存储结构,通过哈希函数将 Key 散列开,Key 哈希值相同的 Value 一般会以单链表结构存储。哈希表查找效率很高,常用于内存型存储服务如 Memcached、Redis。Redis 除了哈希表,因为其支持的操作的数据类型很多,所以还有像 Skiplist、SDS、链表等存储结构,并且 Redis 的哈希表结构可以通过自动再哈希进行扩容。


哈希表一般存储在内存中,随着哈希表数据增多,会影响查询效率,并且内存结构也没法像磁盘那样可以持久化以及进行数据恢复。Redis 默认提供了 RDB 持久化方案,定时持久化数据到 RDB。用 RDB 来做数据恢复、备份是很合适的方案,但是因为其定期执行,所以无法保证恢复数据的一致性、完整性。Redis 还支持另一种持久化方案——基于 AOF(Append Only File) 方式,对每一次写操作进行持久化,AOF 默认不启用,可以通过修改 redis.conf 启用,AOF 增加了 IO 负荷,比较影响写性能,适合需要保证一致性的场景。


SSTable

在我们平常在 Linux 上分析日志文件的时候,比如用 grep、cat、tail 等命令,其实可以想象成在 Query 一个持久化在磁盘的 log 文件。我们可以用命令轻松查询以及分析磁盘文件,查询一个记录的时间复杂度是 O(n) 的话(因为要遍历文件),查询两个记录就是 2*O(n),并且如果文件很大,我们没法把文件 load 到内存进行解析,也没法进行范围查询。


SSTable(Sorted String Table) 就解决了排序和范围查询的问题,SSTable 将文件分成一个一个 Segment(段),不同的 Segment File 可能有相同的值,但每个 Segement File 内部是按照顺序存储的。不过虽然只是将文件分段,并且按照内容顺序(Sorted String)存储可以解决排序,但是查询磁盘文件的效率是很低的。


为了能快速查询文件数据,可以在内存中附加一个 KV 结构的索引:(key-offset)。key 值是索引的值并且也是有序的,Offset 指向 Segment File 的实际存储位置(地址偏移)。


如下图简单画了一个有内存 KV 存储的 SSTable 数据结构:




这个 k-v 结构的存储结构又叫 Memtable,因为 Memtable 的 key 也是有序的,所以为了实现内存快速检索,Memtable 本身可以使用红黑树、平衡二叉树、skip list 等数据结构来实现。Ps:B-Tree、B+Tree 的结构适合做大于内存的数据的索引存储(如 MySQL 使用 B+ 树实现索引文件的存储),所以其更适合磁盘文件系统,一般不会用来实现 Memtable。


SSTable 也是有些局限性的,内存的空间是有限的,随着文件数越来越多,查询效率会逐渐降低。为了优化查询,可以将 Segment File 进行合并,减少磁盘 IO,并且一定程度持久化 Memtable(提高内存查询效率)——这就是 LSM-tree(Log-structured Merge-Tree)。LSM-tree 最初由 Google 发布的 Bigtable 的设计论文 提出,目前已经被广泛用于列族数据库如 HBase、Cassandra,并且 Google 的 LevelDB 也是用 LMS-tree 实现,LevelDB 的 Memtable 使用的是 skip list 数据结构。


这种提供 SSTable 合并、压缩以及定期 flush Memtable 到磁盘的优化,使 LMS-tree 的写入吞吐量高,适合高写场景。下面以 Cassandra 为例介绍下 LMS-tree 的典型数据流。


(1)Cassandra LMS-tree 写


数据先写到 Commit Log 文件中(Commit Log 用 WAL 实现)WAL 保证了故障时,可以恢复内存中 Memtable 的数据。


数据顺序写入 Memtable 中。


随着 Memtable Size 达到一定阀值或者时间达到阀值时,会 flush 到 SSTable 中进行持久化,并且在 Memtable 数据持久化到 SSTable 之后,SSTables 都是不可再改变的。


后台进程会进行 SSTable 之间的压缩、合并,Cassendra 支持两种合并策略:对于多写的数据可以使用 SizeTiered 合并策略(小的、新的 SSTable 合并到大的、旧的 SSTable 中),对于多读的数据可以使用 Leveled 合并策略(因为分层压缩的 IO 比较多,写多的话会消耗 IO),详情可以参考 when-to-use-leveled-compaction。


(2)Cassandra LMS-tree 读


先从 Memtable 中查询数据。


从 Bloom Filter 中读取 SStable 中数据标记,Bloom Filter 可以简单理解为一个内存的 set 结构,存储着被“删除”的数据,因为刚才介绍到 SSTable 不能改变,所以一些删除之后的数据放到这个 set 中,读请求需要从这个标记着抛弃对象们的集合中读取“不存在”的对象,并在结果中过滤。对于 SSTables 中一些过期的,会在合并时被清除掉。


从多个 SSTables 中读取数据。


合并结果集、返回。另外,对于“更新”操作,是直接更新在 Memtable 中的,所以结果集会优先返回 Memtable 中的数据。


BTree、B + Tree

BTree 和 B + Tree 比较适合磁盘文件的检索,一般用于关系型数据库的索引数据的存储,如 Mysql-InnoDB、PostgreSQL。为了提高可用性,一般 DB 中都会有一个 append-only 的 WAL(一般也叫 redo-log)用来恢复数据,比如 MySQL InnoDB 中用 binlog 记录所有写操作,binlog 还可以用于数据同步、复制。


使用 Btree、B+Tree 的索引需要每个数据都写两次,一次写入 redo-log、一次将数据写入 Tree 中对应的数据页(Page)里。LMS-tree 结构其实需要写入很多次,因为会有多次的数据合并(后台进程),因为都是顺序写入,所以写入的吞吐量更好,并且磁盘空间利用率更高。而 B 树会有一些空的 Page 没有数据写入、空间利用率较低。读取的效率来说,Btree 更高,同样的 key 在 Btree 中存储在一个固定的 Page 中,但是 LSM-tree 可能会有多个 Segment File 中存储着同个 Key 对应的值。


小结

本篇介绍了很多分布式存储服务,在实际的开发中,需要结合领域服务的特点选择。有的微服务可能只需要一个Neo4j,有的微服务只需要 Redis。微服务的架构应该可以让领域服务的存储更加灵活和丰富,在选择时可以更加契合领域模型以及服务边界。


文章后半部分介绍了部分存储服务的数据结构。了解了实现的数据结构可以让我们更深刻理解存储引擎本身。从最简单的 append-only 的文件存储,再到哈希表、SSTable、BTree,基本涵盖了目前流行的存储服务的主流数据结构。如果想深入理解 LSM-tree,可以读一下 BigTable 的那篇经典论文。


除了数据库服务,像 Lucene 提供了全文索引的搜索引擎服务,也使用了类似 SSTable 的结构。对于用 Docker 部署 Elasticsearch 集群的实践可以参考下之前写的 Elasticsearch 实践(一)用 Docker 搭建 Elasticsearch 集群,Elasticsearch 实践(二)用 Docker 搭建 Elasticsearch 集群。