第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”。

1-29

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

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

以上只是单个点和单维度关系的例子,在实际中 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 数据结构:

2-21

这个 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 集群。

参考资料
Bigtable: A Distributed Storage System for Structured Data
基于 Redis 实现分布式锁-Redisson 使用及源码分析
Elasticsearch 实践(一)用 Docker 搭建 Elasticsearch 集群
Elasticsearch 实践(二)用 Docker 搭建 Elasticsearch 集群
Upgrade Elasticsearch 5

(全文完)

(转载本站文章请注明作者和出处 第02课:微服务架构下的分布式场景及⽅案——数据存储