之一:来自业务的挑战
作为一个电子商务企业,从一开始,数据库及其事务能力在淘宝一直扮演着十分关键的角色,淘宝积累了丰富的数据库的架构和规划等方面的经验,产生了众多优秀的DBA。
淘宝是一家迅速发展的公司。全球网站排名公司Alexa提供的数据显示,2010年4月27日,Amazon、Ebay的用户占全球互联网用户的百分比分别为3.47%和2.68%,而淘宝的用户占全球互联网用户的百分比则达到了4.1%,淘宝网日独立访问量从此超过了Amazon和Ebay。
淘宝的数据规模及其访问量对关系数据库提出了很大挑战:数十亿条的记录、数TB的数据、数千TPS、数万QPS让传统的关系数据库不堪重负,单纯的硬件升级已经无法使得问题得到解决,水平拆库也并不总是凑效。下面来看一个实际的例子。
收藏夹是线上应用之一,包含收藏info表(一条一条的收藏信息)和收藏item表(被收藏的宝贝和店铺)等:
- 收藏info表保存收藏信息条目,数十亿条
- 收藏item表保存收藏的宝贝和店铺的详细信息,数亿条
- 热门宝贝可能被多达数十万买家收藏
- 每个买家可能收藏千个宝贝
- 宝贝的价格、收藏人气等信息随时变化
- 收藏夹排序(例如按宝贝价格)后展示
收藏夹的挑战是:每次收藏夹展示的时候需要从收藏item表中读取收藏的宝贝(店铺)的最新信息,然后进行排序等处理。如果买家的收藏条目比较多(例如1000条),那么查询对应的item的时间会较长:假设平均每条查询时间是5ms,则1000条的查询时间可能达到5s。这么长的时间很难得到好的用户体验。
如果把收藏的宝贝(店铺)的详细信息实时融入到收藏info表,则上述查询收藏item表的操作就不再需要了。但是,由于许多热门商品可能有数千、数万乃至数十万人收藏,这些热门商品的详细信息的改动可能导致收藏info表的大量改动,并使得收藏info表不堪重负。
淘宝应对上述挑战的是其海量数据库项目,目前已经解决了上述问题:新方案已经在测试中,与水平拆库相比,新方案使用了较少的服务器且性能更高。
之二:一致性选择
众所周知,一致性是数据最关键的属性之一。2000年,Eric Brewer教授在ACM分布式计算年会上指出了著名的CAP理论:
Brewer, E. A. 2000. Towards robust distributed systems. In Proceedings of the 19th Annual ACM Symposium on Principles of Distributed Computing (July 16-19, Portland, Oregon)
即分布式系统不可能满足一致性(C: Consistency),可用性(A: Availability)和分区容错性(P: Tolerance of network Partition)这三个需求。
大约两年后,Seth Gilbert 和 Nancy lynch两人证明了CAP理论的正确性:
Gilbert , S., Lynch, N. 2002. Brewer's conjecture and the feasibility of consistent, available, partition-tolerant Web services. ACM SIGACT News 33(2)
几种常见的一致性类型有:
- 强一致性:系统中的某个数据被成功更新(事务成功返回)后,后续任何对该数据的读取操作都得到更新后的值。这是传统关系数据库提供的一致性模型,也是关系数据库深受人们喜爱的原因之一。
- 弱一致性:系统中的某个数据被更新后,后续对该数据的读取操作得到的不一定是更新后的值,这种情况下通常有个(inconsistency window)存在:即数据更新完成后在经过这个“不一致性时间窗口”,后续读取操作就能够得到更新后的值。
- 最终一致性:属于弱一致性的一种,即某个数据被更新后,如果该数据后续没有被再次更新,那么最终所有的读取操作都会返回更新后的值。
关于最终一致性,Werner Vogels提出了NWR模型(Eventually Consistent - Revisited, By Werner Vogels on December 23, 2008 12:15 AM, http://www.allthingsdistributed.com/2008/12/eventually_consistent.html):
- N:数据复制的份数(the number of nodes that store replicas of the data)
- W:数据更新完成前需要到达的节点数(the number of replicas that need to acknowledge the receipt of the update before the update completes)
- R:为了读取正确数据需要读取的节点数(the number of replicas that are contacted when a data object is accessed through a read operation)
Werner Vogels还写到,如果W+R > N,那么读写节点有重叠,读总是能够得到最新的数据,这就是强一致性。在传统的一主一备同步复制的关系数据库中,N=2,W=2,R=1;在非同步复制模型中,W变成1,此时W+R=N,一致性也就无法保证。
不过,NWR模型只代表了一类情形,例如,在传统的一主一备的非同步复制的关系数据库中,尽管N=2,W=1,R=1,如果只有主库提供服务,则一致性仍然是保证的,不过主机异常时,服务的恢复不是实时的,因此CAP理论依然适用。
在调研中,我们发现一些项目正在或倾向于弱一致性或最终一致性,咋看这似乎表明这些工程师偏爱弱一致性或最终一致性。然而,在经过仔细沟通和深入分析后,我们发现,这些项目采用弱一致性或最终一致性,其实是在高数据量(十几亿条记录、数TB数据)和高访问量(数千TPS、数万QPS)需求压力之下的无奈选择。如果两个系统都能满足上述高数据量和高访问量需求且成本差异不是很大,那么在强一致性和若一致性(或最终一致性)两者中他们会毫不犹豫地选择前者。
显而易见,作为整个系统中最为基础的部件,如果数据库的数据是弱一致,那么上层应用就不得不承受这种弱一致导致的种种后果,从上层应用的角度看,这并不是十分友善的行为。由于上述原因,我们决心在我们的海量数据库中实现与传统关系数据库相同的强一致性,因为我们相信这种强一致性不仅会简化数据库的管理,减轻数据库管理的工作量,尤其重要的是,上层应用不再需要关注数据的不一致性,应用程序也会因此而简化,并且易于开发和维护。
之三:事务的ACID
每个事务使得数据库从一个一致的永久状态原子地转移到一个新的一致的永久状态,可以说,事务的ACID(the transactional properties of Atomicity, Consistency, Isolation and Durability)属性是数据库事务的灵魂:
· 原子性
事务的原子性首先体现在事务对数据的修改,即要么全都执行,要么全都不执行,例如,从银行账户A转一笔款项a到账户B,结果必须是从A的账户上扣除款项a并且在B的账户上增加款项a,不能只是其中一个账户的修改。但是,事务的原子性并不总是能够保证修改一定完成了或者一定没有进行,例如在ATM机器上进行上述转账,转账指令提交后通信中断或者数据库主机异常了,那么转账可能完成了也可能没有进行:如果通信中断发生前数据库主机完整接收到了转账指令且后续执行也正常,那么转账成功完成了;如果转账指令没有到达数据库主机或者虽然到达但后续执行异常(例如写commit log失败或者账户余额不足),那么转账就没有进行。要确定转账是否成功,需要待通信恢复或者数据库主机恢复后查询账户交易历史或余额。事务的原子性也体现在事务对数据的读取上,例如一个事务对同一数据项的多次读取的结果一定是相同的。
· 一致性
事务需要保持数据库数据的正确性、完整性和一致性,有些时候这种一致性由数据库的内部规则保证,例如数据的类型必须正确,数据值必须在规定的范围内,等等;另外一些时候这种一致性由应用保证的,例如一般情况下银行账务余额不能是负数,信用卡消费不能超过该卡的信用额度等。
· 隔离性
许多时候数据库在并发执行多个事务,每个事务可能需要对多个表项进行修改和查询,与此同时,更多的查询请求可能也在执行中。数据库需要保证每一个事务在它的修改全部完成之前,对其他的事务是不可见的,换句话说,不能让其他事务看到该事务的中间状态,例如,从银行账户A转一笔款项a到账户B,不能让其他事务(例如账户查询)看到A账户已经扣除款项a但B账户却还没有增加款项a的状态。
· 持久性
事务完成后,它对于数据库的影响是永久性的,即使系统出现各种异常也是如此。
出于性能考虑,许多数据库允许使用者选择牺牲隔离属性来换取并发度,从而获得性能的提升。SQL定义了4种隔离级别:
Ø Read uncommitted (RU):读取未提交的数据,即其他事务已经修改但还未commit的数据,这是最低的隔离级别;
Ø Read committed (RC):在一个事务中,对同一个项,前面的读取跟后面的读取结果可能不一样,例如第一次读取时另一个事务的修改还没有提交,第二次读取时已经提交了;
Ø Repeatable read (RR):可重复读取,在一个事务中,对同一个项,前面的读取跟后面的读取结果一样;
Ø Serializable (S):可序列化,即数据库的事务是可串行化执行的,就像一个事务执行的时候没有别的事务同时在执行,这是最高的隔离级别;
隔离级别的降低可能导致读到脏数据或者事务执行异常,例如:
Ø Lost update (LU):两个事务同时修改一个数据项,但后一个事务中途失败退出,则对数据项的两个修改可能都丢失;
Ø Dirty Reads (DR):一个事务读取某数据项,但另一个事务更新了此数据项却没有提交,这样所有的操作可能都得回滚;
Ø Non-repeatable Reads (NRR):一个事务对同一数据项的多次读取可能得到不同的结果;
Ø Second lost updates problem (SLU):无法重复读取的特例:两个并发事务同时读取和修改同一数据项,则后面的修改可能使得前面的修改失效;
Ø Phantom Reads (PR):事务执行过程中,由于前面的查询和后面的查询的期间有另外一个事务插入数据,后面的查询结果出现了前面查询结果中未出现的数据。
隔离级别与读写异常(不一致)的关系如下:
| LU | DR | NRR | SLU | PR |
RU | Y | Y | Y | Y | Y |
RC | N | N | Y | Y | Y |
RR | N | N | N | N | Y |
S | N | N | N | N | N |
容易发现,在最高隔离级别serializable下,数据不会出现读写的不一致。
不同的数据库支持的隔离级别不尽相同,例如oracle只支持read committed和serializable两个级别,MySQL支持全部四个级别。
在我们的海量数据库的设计和实现过程中,经过分析发现,最高的隔离级别serializable也不会带来性能的损失,所以我们只实现了serializable这一个级别。
之四:系统架构与跨表事务
在淘宝海量数据库的研制的早期,我们曾经陷于一个十分困难的境地:无论从数据量还是访问量,我们已经不可能把一个数据库的数据置于一台服务器,即使我们能够让一台机器服务高达几个 TB的数据、实现几万 QPS的服务能力,因此,分布式系统不可避免,然而,在内部实现上,如何拆表 (拆库 )以及如何实现数据库的事务成为了一个很大的挑战和十分艰难的抉择:
一种选择是当前数据库的常用的水平拆库,淘宝在这方面已经有很多实践。通常的做法是对主键进行hash或者取模(后者其实是一种特殊的hash),把数据分布到不同的DB服务器上,客户端通过路由或规则访问特定的数据库,将整个系统的数据和访问负载分散到多台服务器上,降低了单台机器的负载压力。但这种做法存在一些弊端:
第一,数据和负载增加后增加机器的操作比较复杂;
第二,跨行/跨表的修改通常涉及到多台机器,影响事务的性能;
第三,范围查询需要访问几乎所有机器;
第四,RDBMS单机数据量小(例如MySQL单机支持200GB可能比较合适),对于一些访问量不高且数据量大的数据库,消耗的机器资源较大;
由于这些原因,我们不打算在海量数据库项目中采用这个做法。
另一种选择是Google的BigTable的做法,即按主键的范围动态拆库。具体做法是把整个表看成主键的B+树,每个叶子节点(大约两百多MB)对应一个连续的主键范围,叶子节点可能因为修改删除等变得太大或太小从而进行分裂或者合并,容错、故障恢复以及负载平衡等都以叶子节点为单位(关于BigTable的更多信息可参加另外一篇博客:云计算之分布式表格系统或者BigTable论文原文:“Bigtable: A Distributed Storage System for Structured Data”)。
这种架构的好处是系统易于扩展:简单地增加机器就可以,负载平衡也比前一种方案更好,范围查询很容易实现且十分高效,因为我们决定采用这个架构。
然而,这个架构最大的困难是事务的实现,因为我们需要跨行跨表的事务,而不是像BigTable一样的单行事务。这个问题困扰了我们不短的时间,直到我们找到一个方法巧妙地解决了这个问题。理论分析和代码实现都证明这个方法十分地简单、高效。
后来我们有机会拜读了Google的关于分布式事务的文章(“Large-scale Incremental Processing Using Distributed Transactions and Notifications”),感受了其中的Percolator的高性能,也感受了它的复杂度,同时也发现,虽然它通过使用15000个CPU核达到了创纪录的11200 tps (TPC-E benchmark),但它的平均事务响应时间为2s-5s,并不符合我们业务的平均几十毫秒的响应时间的需求,而且我们也没有足够的时间和人力资源来开发一个类似的系统及其底层BigTable等系统。(关于Google的分布式事务,欢迎感兴趣的朋友访问一个友人的wiki:
http://nosql-wiki.org/wiki/bin/view/Main/GoogleDistributedTransactions)
之五-数据结构
前面曾经提到,采用了类似于Goolge的BigTable的数据组织方式,例如:
- 每个表按主键(row key)组成一个B+树;
- 主键是binary string;
- 一个叶子节点包含表的一个前开后闭的主键范围(rk1,rk2]内的数据;
- 每个叶子节点内部按更小的主键范围划分为多个块(block)并内建块索引(block index);
- 每个块的大小通常在8KB~64KB之间并内建行索引;
- 数据压缩以块为单位;
- 数据压缩算法由用户指定并可随时变更;
- 每个叶子节点的大小可在一定范围内波动(例如上下50%);
- 叶子节点可能合并或者分裂;
- 所有叶子节点基本上是均匀、随机地分布在多台机器(称为chunk server)上;
- 通常情况下每个叶子节点有3个副本;
- 叶子节点是负载平衡和任务调度的基本单元;
- 允许bloom filter;
- ......
一旦一个叶子节点被访问,它的块索引(block index)将会进入到内存cache中,这使得后续访问中定位块(block)时不再需要访问磁盘;反过来,如果一个叶子节点长时间不被访问,它的块索引也可能被从内存cache中淘汰出去。近期访问过的块(block)以及cell(table,row key,column,value)也常常能进入cache。
虽然有许多相似的地方,淘宝海量数据库的数据结构与BigTable也有一些不同:
- 主键(binary string)可以有结构或其他约束;
- 值有多种数据类型(数值,字符串,时间…);
- 类似于DBMS的schema并且根据schema进行强制数据类型检查;
- 没有BigTable的column family及qualifier;
- 支持稀疏表,也支持稠密表;
- 叶子节点的每个副本同时提供服务;
- 没有支持BigTable一个cell多个时间(timestamp)版本;
- ......
由于许多情况下业务数据其实是稠密的结构化数据,所以我们增加了稠密数据格式和类似于DBMS的schema,以减少数据量、方便业务并缩短数据操作时间(例如避免字符串->数值->字符串的转换等)。
由于叶子节点的每个副本同时提供服务,因此少量chunk server的异常并不增加系统的查询和修改的响应时间:即使没有cache,我们系统对比较简单的查询的响应时间是几毫秒~十几毫秒之间,对比较简单的修改的响应时间在十几个毫秒到几十毫秒之间,避免了少量chunk server的突然故障导致的大量用户请求的堆积。这个特性对于实时线上服务系统是十分友好的。