本篇文章谢绝转载, 欢迎转发
几年之前, 曾不自量力的想要写一个兼容 RDBMS 和 NoSQL 的数据库, 结果仅实现了一个 Raft 协议, 写了一棵 BTree , 就放弃了. 使用 Golang 写这个算是比较简单的了, 但过程难以言诉, 有点蚂蚁撼大树了.
而个人, 由于工作的关系, 也已经有四五年没有和 SQL 打交道了. 最近重拾, 感慨良多.
像 MySQL 这种 RDBMS , 天生是存在分布式缺陷的, 在海量数据的今天, 很容易就达到瓶颈. 过去这么多年, 一点长进都没有. 所以经常的操作就是换存储引擎, 分库分表, 引入中间件, 阉割功能.
分布式存储特征
能让你不忍割舍的, 一个就是 MySQL 协议, 你用惯了; 一个就是事务, 你怕丢数据.
幸运的是, 大多数互联网业务不需要强事务, 甚至连 MySQL 协议都不需要. 接下来我们看一下要将一个传统的 MySQL 改造成分布式的存储, 是有多么的困难.
CAP 理论应该是人尽皆知的事情了, 在此不多提.
单机上的任何数据都是不可信的, 因为硬盘会坏, 会断电, 会被挖光缆. 所以一般通过冗余多个副本来保证数据的安全. 副本的另外一个作用, 就是提供额外的计算能力, 比如某些请求, 会落到副本上. 副本越多, 可用性越高 .
而加入副本以后, 就涉及到数据的同步问题. 即使是最快的局域网, 也会存在延迟, 更不用说机器性能差异引起的同步延迟. 这就存在一个问题, 读副本的请求读到的数据, 可能不是最新的, 这就是数据的一致性发生了改变. 当然有些手段能保证数据的一致性, 但 副本越多, 延迟越大 .
副本的加入还会引入主从的问题. 主节点死掉以后, 要有副本节点顶上去, 这个过程的协调需要时间, 其间部分不可用.
而当一类数据足够大 (比如说某张表), 在其上的操作已经非常耗时的情况下, 就需要对此类数据进行切割, 将其分布到多台机器上. 这个切割过程就是 Sharding, 通过一定规则的分片来减少单次查询数据的规模, 增加集群容量.
当某些查询涉及到多个分片, 这个过程就比较缓慢了. 协调节点需要与每个节点进行沟通, 然后 聚合查询的结果, 分片数越多, 时间越长 .
一般, 在一个维度上的分库都会遇到上述问题, 可怕的是你可能会有多个维度的需求. 对于一些 NoSQL 来说, 每一个维度, 都需要冗余一份数据, 这一般是膨胀性的.
集群的规划并不是一成不变的, 你的集群可能会加入新的节点; 也可能有节点因为事故离线; 也可能因为分片维度的问题, 数据发生了倾斜. 当这种情况发生, 集群间的数据会发生迁移, 以便达到平衡. 这个过程有些是自动的, 也有些是手动进行触发. 这个过程也是最困难的: 既要保证数据的增量迁移, 又要保证集群的正确服务.
如果你想要事务 (很多情况是你不懂技术的 Leader 决定), 那就集中存储, 不要分片. 事务是很多性能场景和扩展场景的万恶之源, 流量大了你会急着去掉它.
副本
针对一个分片的数据, 只能有一个写入的地方, 这就是 master , 其他副本都是从 master 复制数据.
副本能够增加读操作的并行读, 但会读到脏数据. 如果你想要读到的数据是一致的, 可以采用同步写副本的方式, 比如 KAFKA 的 ack=-1 , 只有全部同步成功了, 才认为本次提交成功.
但如果你的副本太多, 这个过程会非常的慢. 你可能想要通过分配写入和读取的副本个数来协调写入和读取的效率, Quorum 的 R+W>N 就是一个权衡策略 . 这个过程可以简单的用抽屉原理来解释.
上面的这个过程比较简单, 所以需要有点复杂的压下轴. 一个名门就是 Paxos , 复杂的很, 以前看了一个星期也没全部搞懂 -.-.
ZAB 协议是 ZooKeeper 在 Paxos 协议的基础上进行扩展而来的, 说实话也没看懂, 而且 ZK 的源代码也非常的...
唯一看得懂的就是 Raft 协议, 这个是 Etcd 和 Consul 的基础, 是简化版的 Paxos , 目前来看是高效且可靠的.
副本是用来做 HA 的, 所以 master 死了, 要有副本顶上来. 这个过程就涉及到 master 的选举.
像 kafka , 借助 zookeeper 来进行主分区的选举. 而 ES 是使用 Bully 算法, 通过选出 ID 最大的节点当作 master. 无论什么方式, 都是要从一堆机器中, 找到一个唯一的 master 节点, 而且在选举的过程中, 都需要注意一个 脑裂 问题 (也就是不小心找到俩了). master 选举通常都是投票机制, 所以最小组集群的台数一般都设置成 n/2+1 .
这也是为什么很多集群推荐奇数台的原因!
cassandra 采用了另外一种协议来维护集群的状态, 那就是 gossip , 是最终一致性的典范.
副本机制在传统的 DB 上也工作的很好. 比如 MySQL 通过 binlog 完成副本的同步; PostgreSQL 采用 WAL 日志完成同步. 但涉及到主从的切换, 尤其是有多个从库的情况下, 一般都不能够自动化执行.
分片
分片就是对资料的切割, 也就是一套主从已经装不下了. 分片的逻辑可以放在客户端, 比如驱动层的数据库中间件, Memcache 等; 也可以放在服务端, 比如 ES,Mongo 等.
分片的信息组成了一组元数据, 存放了切割的规则. 这些信息可以借助外部的存储比如 KAFKA; 也有的直接同步在集群每个节点的内存中, 比如 ES. 比较流行的 NoSQL 主从信息 最小维度一般都是分片, 一个节点上同时会有 master 分片和其他分片的副本.
分片的规则一般有下面几种:
Round-Robin 资料轮流落进不同的机器, 数据比较平均, 适合弱相关性的数据存储. 坏处是聚合查询可能会非常慢, 扩容, 缩容难.
Hash 使用某些信息的 Hash 进行寻路, 客户端依照同样的规则可以方便的找到服务端数据. 问题与轮询类似, 数据过于分散且扩容, 缩容难. Hash 同样适合弱相关的数据, 并可通过 一致性哈希 来解决数据的迁移问题.
Range 根据范围来分片数据, 比如日期范围. 可以将一类数据归档到特定的节点, 以增加查询速度. 此类分片会遇到热点问题, 会冷落很多机器.
自定义自定义一些分片规则. 比如通过用户的年龄, 区域等进行切分. 你需要维护大量的路由表, 然后自己控制数据和访问的倾斜问题.
嵌套属于自定义的一种, 路由规则可以嵌套. 比如首先使用 Range 进行虚拟分片, 然后再使用 Hash 进行实际分片. 在实际操作中, 这很有用, 需要客户端和服务端的结合才能完成.
路由的元数据不能太多, 否则它本身就是一个访问瓶颈; 也不能够太复杂, 否则数据的去向将成为谜底. 分布式系统的数据验证和测试是困难的, 原因就在于此.
可惜的是, 使用用户的年龄, 和使用用户的地域进行分片, 数据的分布完全不同. 增加了一个维度的查询速度, 会减慢另一个维度的性能, 这是不可避免的. 切分字段的选择非常重要, 如果几个维度都很必要, 解决的方式就是冗余 -- 按照每个切分维度, 都写一份数据.
大部分互联网业务一般通过用户 ID 即可找到用户的所有相关信息, 规划一个分层的路由结构即能满足需求. 但数据统计类的需求就困难的多, 你看到的很多年度报告, 可能是算了个把月才出来的.
一般组成结构
数据写入简单, 因为是按条写的. 但数据的读取就复杂多了, 因为可能涉及到大量分片, 尤其是 AGG 查询业务. 一般会引入中间节点负责数据的聚合, 因为大量的计算会影响 master 的稳定, 这是不能忍受的.
通过区分节点的职责, 可以保证集群的稳定. 根据不同的需要, 会有更多的协调节点被加入.
在做分布式之前, 先要确保在单机场景能够最优. 除了一些缓冲区优化, 还有索引. 但分布式是一直缺少一个索引的, 曾经想设计一种基于内存的 分布式索引 , 但还是赚钱养家要紧.
存储要有一个强大的查询语法引擎, 目前来看非 SQL 引擎莫属. 抽象成一棵巨大但语法树, 然后在其上编程. 像 Redis 这样简单的文本协议, 是一个特定领域的特例.
分布式事务
ACID 是强事务的单机 RDBMS 的特性. 涉及到跨库, 会有二阶段提交, 三阶段提交之类的分布式事务处理.
数据库的分布式事务实现叫做 XA , 也是一种 2PC ,MySQL5.5 版本开始已经支持这种协议.
2PC 会严重影响性能, 并不是和高并发的场景, 而且其实现复杂, 牺牲了一部分可用性.
另一种常用的方式就是 TCC (补偿事务).TCC 的本质是: 对于每一个操作, 都需要一个与之对应的确认和撤销操作. 但可惜的是, 在确认和撤销阶段, 也有一定概率发生问题, 需要 TCC 的 TCC; 很多业务根本没有相应的逆操作, 比如删除某些数据, TCC 就没法玩了.
TCC 需要大量编码, 适合在框架层统一处理.
还有一种思路是将分布式事务合并成本地事务来处理. 也就是一个事务包含一条消息 + 一堆数据库操作, 成功执行完毕后再设置消息的状态, 失败后会重试. 此种方式将消息强制耦合到业务中, 且消息系统本身的事务问题也是一个需要考虑的因素.
分布式事务除了要写多个分片的协调问题, 还有并发读写某一个值的问题.
比如有很多请求同时在修改一个余额. 常用的方法就是加锁, 但是效率太低. 我们回忆一下 java 如何保证这种冲突.
对于读远大于小的操作, 可以使用 CopyOnWrite 这种方式优化; 对于原子操作, 可以使用 CompareAndSet 的方式先比较再赋值. 要想保证余额的安全, 使用后者是很有必要的.
MVCC 是行级别锁的一种妥协, 他用来保证一个值在某个事务中是一致的, 避免了脏读和幻读, 但并不能保证数据的安全, 这点一定要注意.
最终一致性
举个栗子: 你的家庭资金共有 500w, 你私自借给好基友 500 万. 使出了洪荒之力在年底讨回了借款, 并追加了利息. 在老婆查帐的时候, 原封不动的展示给她看. 这就是最终一致性.
我习惯性这样描述: 在可忍受的时间内, 轻过程, 重结果, 达成一致即可 . 虽然回味起来心有余悸.
在这种情况下, 不需要过多的使用分布式事务来控制. 你只管写你的数据, 不用管别人是否写成功. 我们通过其他的手段来保证数据的一致性.
一种方式是常见的定时任务, 不断的扫描最近生成的数据, 进行补齐. 如果程序实在无法判断, 则写入到异常表中人工介入.
另外一种方式就是重放数据, 将这个过程重新执行一遍, 要求业务逻辑是可重入的 (幂等). 如果依然有问题, 还是需要人工介入.
比较幸运的是, 良好的设计下, 这些异常状况产生的几率是比较小的, 投入和产出会超出期望. 采用了 BASE 的系统, 选择的是弱一致性, 高度依赖 业务监控 组件 来及时的发现问题.
这种思想已经被大多数研发所接受, 除非你的老板可忍受时间很短!
哦, BASE 的全称是:
- Basically Available(基本可用),
- Soft state(软状态),
- Eventually consistent(最终一致性)
总结
作为研发人员, 是不能对软件有好恶倾向的, 只有合适与不合适的区别. 没有精力去改进这些系统, 只能通过不断的取舍, 组合它们的优点.
Greenplum 和 Elasticsearch, 在分布式 DB 领域, 是两个典型实现, 它们都以强大的分布式能力著称.
Greenplum 代表了 RDBMS 是如何向分布式发展的, 当然它是建立在强大的 PostgreSQL 基础上的.
ES 是建立在 Lucene 上的全文检索搜索引擎, 但好像大家也拿它当数据库使用. 源码是 java 的, 有很多值得推敲的地方.
缓慢的 I/O 设备, 再也无法压榨单机的性能, 注定了要走向分布式. 但前路依然漫漫, 看看五花八门的分布式数据库就知道了.
没有谁, 能一统江湖.
来源: http://www.tuicool.com/articles/QfimUbM