2015 年 11 月 28 日,架构与运维的年度大趴——又拍云架构与运维大会·北京站圆满落幕了。近 1500 名来自全国各地的 IT 小伙伴们让现场热火朝天,而 23 位享誉互联网技术业界的大神们的登场,58到家技术总监沈剑在会上作了题为《58同城数据库架构最佳实践》,以下是分享实录:

image.png

我是 58 沈剑,今天的分享主题是《58 同城数据库架构最佳实践》,本次讨论的问题会非常聚焦。

数据库的基本概念

基本概念只有这一页 PPT,让大家就一些数据库方面的概念达成一致。

image.png
首先是“单库”。开始的时候数据库都是这么玩的,几乎所有的业务都有这样的一个库。

接下来是“分片”,数据库的分片是解决数据量大的问题,如果数据量非常大,要做水平切分,有一些数据库支持 auto sharding,之前 58 同城也用过两年 mongoDB,后来发现 auto sharding 功能不太可控,不知道什么时间进行迁移数据,数据迁移过程中会有比较大的锁,读写被阻塞,业务会有毛刺,我们现在又迁移回了 mysql。

如果是单库的话,所有的请求丢到这个库里面就行了,一旦有分片,多库分布在不同的实例上,到底访问哪一个实例呢?就会出现数据路由的概念。

常用的互联网数据库路由方法有三种:

  • 其一是按照范围路由。比如有两个分片,可以一个范围是 0-1 亿,一个范围是 1 亿- 2 亿,这样来路由。这个方式的优点是非常的简单,马上知道数据在哪一个分片上,并且这个方式非常好扩展,假如两个分片不够了,增加一个 2 亿- 3 亿的分片即可。但是按照范围来分片的缺点是:虽然数据的分别是均衡的,每一个库的数据量是差不多的,但请求的负载会不均衡,例如有一些业务场景,新注册的用户可能活跃度更高,会导致 ID 范围较大的库的请求负载更高。
  • 其二是按照 hash 分片。如果是两个分片,模 2 分片即可。这个方式的优点是业务线也很简单,数据分布也是均衡的,请求负载也是均衡的。缺点是如果两个分片数据量过大,要变成三、五个分片,这样比较麻烦,会有数据迁移。
  • 其三是路由服务。前面两个路由的方法有一个缺点,业务线需要耦合路由规则。路由服务可以实现业务线与路由规则的解耦,业务线每次访问数据库之前先调用路由服务,来知道数据究竟存放在哪个分库上。

接下来是“分组”与“复制”,这解决的是扩展读性能,读高可用的问题。根据我们的经验,大部分互联网的业务都是读多写少的场景。淘宝、京东查询商品,搜索商品的请求可能占了 99%,只有下单和支付的时候有写请求,写请求的量是比较少的;58 同城搜索帖子,察看列表页,查看详情页都是读请求,发布帖子是写请求,写请求的量也是比较少的。

大部分互联网的场景都读多写少,所以读性能会先成为瓶颈,怎么快速解决这个问题呢?我们通常使用读写分离,扩充读库的方式来提升读性能。同时也保证了读可用性,一台读库挂了,另外一台读库可以持续的提供服务。

常见数据库的玩法综合了“分片”和“分组”,数据量大进行分片,为了提高读性能,保证读的高可用,进行了分组,80% 互联网公司数据库都是这种软件架构。

可用性架构实践

数据库大家都用,平时除了根据业务设计表结构,根据访问来设计索引之外,会在进行数据库架构的时候考虑可用性吗?应该要考虑。

image.png
我们看一下传统的数据库可用性的玩法。左边一个图是“读”高可用,我们怎么样保证数据库读库的高可用?解决高可用这个问题的思路是冗余。解决站点的可用性冗余多个站点。解决服务的可用性冗余多个服务。解决数据的可用性冗余数据。如果用单读库,保证不了读高可用,我就复制读数据,把读数据复制了两个,一个读库挂了可以提供另外一个,用复制的方式来保证读的可用性。

数据的复制会引发一个副作用,就是一致性的问题。如果是单库,读和写都落在同一个库上,每次读到的都是最新的数据库,不存在一致性的问题。但是为了保证可用性将数据复制到多个地方,而这多个地方的数据绝对不是实时同步的,不然会有同步时延,所以有可能会读到旧的数据。如何解决一致性问题我们在后面再来讲。

很多互联网公司的数据库软件架构都不能够保证“写”的高可用,因为写其实还是只有一个库,如果这个库挂了的话,写会受影响。那大家为什么还用这个架构呢?刚才提到可能 99% 的业务都是“读”业务。比如 58 同城,可能写库挂了,只有 1 %的用户发帖的时候会受到影响,99% 的用户搜索等不会受到影响。

如果要做到“写”的高可用,对数据库软件架构的冲击比较大,不一定值得,为了解决 1% 的问题引入了 80% 的复杂度,所以很多互联网公司都没有解决写数据库的高可用的问题。怎么来解决这个问题呢?思路还是冗余,读的高可用是冗余读库,写的高可用是冗余写库。把一个写变成两个写,做一个双主同步,一个挂了的话,我可以将写的流量自动切到另外一个,写库的高可用性。

用双主同步的方式保证写高可用性会存在什么样的问题?刚才提到,用冗余的方式解保证可用性会存在一致性问题。因为两个主相互同步,这个同步是有时延的,很多公司用到 auto-increment-id 这样的一些数据库的特性,如果用双组同步的架构,一个主 id 由 10 变成 11,在没有同步过去之前,另一个主又来了一个写请求,也由 10 变成 11,双向同步会同步失败,就会有数据丢失。

解决这个双主同步id冲突的方案有两种:一个是双主使用不同的初始值来生成 id,一个库从 0 开始(生成 02468),一个库从 1 开始(生成13579),步长都为 2,这样两边同步数据就不会冲突。另一个方式是不要使用数据库的 auto-increment-id,而由业务层来保证生成的 id 不冲突。

image.png

58 同城没有使用上述两种方式来保证读写的可用性。58 同城使用的“双主”当“主从”的方式来保证数据库的读写可用性。虽然看上去是双主同步,但是读写都在一个主上,另一个主库没有读写流量,完全 standby。当一个主库挂掉的时候,流量会自动的切换到另外一个主库上,这一切对业务线都是透明的,自动完成。

58 同城的这种方案,读写都在一个主库上,就不存同步延时而引发的一致性问题了,但缺点有两个:第一是数据库资源的利用率只有 50%,第二个缺点是没有办法通过增加读库的方式来扩展系统的读性能。58 同城的数据库软件架构如何来扩展读性能呢,我们接着来看下一章。

读性能架构实践

image.png

先看下传统的玩法:第一种玩法是增加从库,通过增加从库来提升读性能,存在的问题是什么呢?从库越多,写的性能越慢,同步的时间越长,不一致的可能性越高。第二种常见的玩法是增加缓存,缓存是大家用的非常多的一种提高系统读性能的一种方法,特别是对于读多写少的互联网的场景非常的有效。

我们看一下常用的缓存怎么玩,上游是业务线的,底下是读写分离主从同步,然后会加一个 cache。对于写操作我们会先淘汰 cache,再写数据库。对于读操作我们会先读 cache,如果 cache hit 则返回数据,如果 cache miss 则读从库,然后把读出来的数据再入缓存,这是常见的缓存的玩法。

传统的 cache 玩法在一种异常时序下,会引发严重的一致性问题,考虑一个特殊的时序:

(1)先来了一个写请求,淘汰了 cache,写了数据库;

(2)又来了一个读请求,读 cache,cache miss了,然后读从库,此时写请求还没有同步到从库上,于是读了一个脏数据,接着脏数据入缓存;

(3)最后主从同步完成;

这个时序会导致脏数据一直在缓存中没有办法被淘汰掉,数据库和缓存中的数据严重不一致。

58 同城也是采用缓存的方式来提升读性能的,那我们会不会有数据一致性问题呢,接着往下看。

image.png
一致性架构实践

58 同城采用“服务+数据库+缓存”一套的方式来保证数据的一致性,由于 58 同城使用“双主当主从用”的数据库读写高可用架构,读写都在一个主库上,不会读到所谓读库的脏数据,所以数据库与缓存的不一致情况也不会存在。

传统玩法中,主从不一致的问题有一些什么样的解决方案呢?我们一起来看一下。

主从为什么会不一致?刚才提到读写会有时延,有可能读到未同步到读库上的旧数据。常见的方法是引入中间件,业务层不直接访问读写库,而是通过中间件访问数据库,这个中间件会记录哪一些 key 上发生了写请求,在这个数据同步时间窗口之内,如果 key 上发生了读操作,就将这个请求路由到主库上去(因为此时从库可能还是旧数据),使用这个方法来保证主库和从库的不一致。

image.png
中间件的方案很理想,那为什么大部分的互联网的公司都没有使用这种方案来保证主从数据的一致性呢?那是因为中间件的技术门槛比较高,有一些大公司,百度,腾讯他们可能有中间件,并不是所有的创业公司互联网公司有这样的中间件产品,而且很多互联网公司的数据一致性的要求并没有那么高。比如说百度搜一个帖子,有一个网站没有搜出来,没啥关系。比如说同城搜一个帖子,可能 5 秒钟之后才搜出来,对用户的体验并没有多大的影响。

除了中间件,读写都路由到主库,58 同城就是这么干的,也是一种解决主从不一致的常用方案。

解决完主从不一致,第二个要解决的是数据库和缓存的不一致,刚才提到 cache 传统的玩法,有可能脏数据入 cache,我们怎么解决呢?两个实践:第一个是缓存双淘汰机制,第二个是建议为所有 item 设定过期时间。

image.png
缓存双淘汰,传统的玩法在进行写操作的时候,先淘汰 cache 再写主库。上文提到,在主从同步时间窗口时间之内可能有脏数据入 cache,我们此时如果再发起一个异步的淘汰,写发生的时候先淘汰 cache 再写主库,异步的 50 毫秒以后再次淘汰 cache,即使 50 毫秒内主从脏数据入了 cache,也会再次淘汰掉。

第二个建议是为所有 item 设定超时时间,例如 10 分钟。极限时序下,即使有脏数据入 cache ,这个脏数据也最多存在十分钟。带来的副作用可能每十分钟,这个 key 上有一个读请求穿透到数据库上,但我们认为这对数据库的从库压力增加是非常小的。

扩展性架构实践

扩展性也是架构师在做数据库架构设计的时候需要考虑的一点。我分享一个 58 同城非常帅气的秒级数据扩容的一个方案。这个方案解决什么问题呢?原来数据库水平切分成 N 个库,现在要扩容成 2N 个库,要解决这个问题。

image.png

假设原来分成两个库,假设按照 hash 的方式分片,则分为奇数库和偶数库,第一个步骤提升从库,底下一个从库放到上面来(其实什么动作都没有做);第二个步骤修改配置,则完成扩容,原来是 2 个分片,修改配置后变成 4 个分片,这个过程没有数据的迁移。原来偶数的那一部分现在变成了两个部分,一个是 M0,一个是 M2,奇数的部分现在变成 M1 和 M3。

M0 库和 M2 库没有数据冲突,只是扩容之后在短时间内双主的可用性这个特性丢失掉了,我们后续可能会做一些操作:把旧的双主给解除掉,为了保证可用性增加新的双主同步,原来拥有全部的数据,现在只为一半的数据提供服务了,我们把多余的数据删除掉,结尾这三个步骤可以事后慢慢操作。整个扩容在过程在第二步提升从库,修改配置其实就秒级完成了,非常的帅气。

这个方案的缺点是只能实现 N 库到 2N 库的扩容,2 变 4、4 变 8,不能实现 2 库变 3 库,2 库变 5 库的扩容,如何能够实现这种扩容呢?

数据库扩展性方面有很多的需求,刚才说的 2 库扩 3 库,2 库扩 5 库是潜在的需求。产品经理经常变化需求,扩充表的属性也是扩展性需求,今年的数据库大会同行也介绍了一些使用触发器来做 online schema change 的方案,但是触发器的局限性在于:第一、触发器对数据库性能的影响比较大;第二、触发器只能在同一个库上才有效,而互联网的场景特点是数据量非常大,并发量非常大,库都分布在不同的物理机器上,触发器没有办法搞。还有一类扩展性需求,底层存储介质发生变化,原来是 mysql 存储,现在要变为 mongodb 存储,这也是扩展性需求(虽然很少),这三类需求怎么扩展能?

image.png
方法是倒库,迁移数据,迁移数据有几种做法,第一种停服务,如果大家的业务能够接受这种方法,强烈建议使用这种方法。有一些游戏公司,晚上一点到两点服务器维护,如果业务上能够接受,这种方法比较简单。

如果业务上不允许停服务,想做到平滑迁移,不停服务。双写法可以解决上面三类问题。双写法迁移数据的第一步是升级服务,原来的服务是写一个库,现在建立新的数据库,比如底层存储介质的变化,我们原来是 mysql 数据库,现在建立好新的 mongo 数据库,然后对服务的所有写接口进行双库写升级。

第二步写一个小程序去进行数据的迁移。比如写一个离线的程序,把两个库的数据重新分片,分到三个库里。也可能是把一个只有三个属性的用户表倒到五个属性的数据表里面。这个数据迁移要限速,倒完之后两个库的数据一致吗?只要提前双写,如果没有什么意外,两边的数据应该是一致的。

什么时候会有意外呢?在倒某一条数据的过程当中正好发生了一个删除操作,这个数据刚被服务双写删除,又被迁移数据的程序插入到了新库中,这种非常极限的情况下会造成两边的数据不一致,所以建议再开发一个小脚本,对两边的数据进行比对,如果发现了不一致,就可以将数据修复。当修复完成之后,我们认为数据是一致的,再将双写又变成单写,数据完成迁移。

这个方式的优点,第一、改动是非常小的,对服务的影响比较小,单写变双写,开发两个小工具,一个是迁移程序,从一个库读数据,另外一个库插进去;还有一个数据校验程序,两个数据进行比对,改动是比较小的。第二、随时可回滚的,方案风险比较小,在任何一个步骤如果发现问题,可以停止操作。比如迁移数据的过程当中发现不对,就把新的数据库干掉,重新再迁。因为在切换之前,所有线上的读服务和写服务都是旧库提供,只有切了以后,才是新库提供的服务。这就是我们非常帅气的一个平滑倒库的方式。

总结

对今天内容做一个简单的总结。首先介绍了单库、分片、复制、分组的概念。分片解决的是数据量大的问题,复制和分组解决的是提高读性能,保证读的可用性的问题。分片会引入路由,常用的三种路由的方法,按照范围、按照 hash,或者新增服务来路由。

image.png
怎么保证数据的可用性?主要的思路是冗余,但会引发数据的不一致,58 同城保证可用性的实践是双主当主从用,读写流量都在一个库上,另一个库 standby,一个主库挂掉流量自动迁移到另外一个主库,只是资源利用率是 50%,并不能通过增加从库的方式提高读性。读性能的实践,传统的玩法是增加从库或者增加缓存。

可能存在的问题。主从可能不一致,同城的玩法是服务加数据库加缓存一套的方式来解决这些问题。一致性的实践,解决主从不一致性有两种方法,一种是增加中间件,中间件记录哪一些 key 上发生了写操作,在主从同步时间窗口之内的读操作也路由到主库。第二种方法是强制读主。数据库与缓存的一致性,我们的实践是双淘汰,在发生写请求的时候,淘汰缓存,写入数据库,再做一个演延的缓存淘汰操作。

第二个实践是建议为所有的 item 设置一个超时时间。扩展性今天分享了 N 库扩 2N 库的扩容方案,还分享了一个平滑双写倒库的方案,解决的问题是两库扩三库,数据库字段的增加,以及底层介质的变化是在互联网大数据量、高并发量的场景下使用的一种平滑倒库的方案。