3.5 数据库扩展
数据库系统无疑是现代Web系统的核心组件。无法想象在数据库停止工作或者查询极端缓慢的情况下,业务系统还能够正常运行,保障数据库系统的高可用性和高性能是非常重要的。在业务飞速发展的场景下,单点的数据库系统往往会出现瓶颈,这时需要扩展数据库系统来适应业务的发展。本节将介绍扩展数据库比较常用的方法和如何在Django中应用这些方法。
3.5.1 扩展方法
简单地说,扩展就是让数据库系统能够处理更多的流量和更多的读写查询。主流的扩展方法有纵向扩展和横向扩展。
纵向扩展采用的是增强单个数据库服务能力的方法,如增加CPU,增加内存和增加存储空间,或者购买更为强大的服务器。这个方法的主要优点是简单和直观,应用层不需要做适配;缺点主要在于硬件的扩容有上限,成本很高,升级困难。
横向扩展采用的是将多个数据库服务组合起来的方法。和纵向扩展对比起来,这个方法的可用性更高,易于升级,同时成本更低;同时对技术要求较高,需要应用层做调整。
本节将主要讨论几种横向扩展的方法。
3.5.2 读写分离
读写分离主要用到了MySQL的复制功能。
MySQL的复制功能允许将来自一个MySQL数据库服务器的数据自动复制到一个或多个MySQL数据库服务器,这是MySQL服务高可用的一种策略。这种策略增加了冗余,当一台数据库服务宕机后,能通过调整另外一台从库来以最快的速度恢复服务。
由于主从复制是单向的(从主服务器到从服务器),因此只有主数据库用于写操作,而读操作可以在多个从数据库上进行。也就是说,如果使用主从复制作为横向扩展解决方案,则至少要定义两个数据源,一个用于写操作,另一个用于读操作。
要使复制功能正常工作,首先主服务器需要将复制事件写入日志,一般称这个日志为Binlog。每当从服务器连接到主服务器时,主服务器都会为连接创建新线程,然后执行从服务器对它的请求。大多数请求会是将Binlog传给从服务器和通知从服务器有新的Binlog写入。
从服务器会起两个线程来处理复制。一个称为I/O线程。这个线程连接到主服务器,从主机读取二进制日志事件,并将它们复制到本地的日志文件中,这个日志称为中继日志。另一个称为SQL线程。这个线程从本地的中继日志中读取事件,然后尽快在本地执行它们。MySQL主从服务器工作过程如图3.2所示。
图3.2 MySQL主从服务器工作过程
通过读和写分别请求不同的数据库服务,可以有效地降低单个MySQL服务的负载,从而提高系统整体的可用性。
在Django中应用读写分离的过程如图3.3所示。
图3.3 在Django中应用读写分离的过程
Django提供的多数据库请求路由可以用来实现读写分离。首先需要配置多个数据库服务,修改settings.py中的DATABASES列表,示例代码如下:
接下来设置数据库路由,我们将写操作应用到主数据库,将读操作应用到从数据库,示例代码如下:
然后修改settings.py,加上:
DATABASE_ROUTERS = ['path.to.DefaultRouter']
当有多个从数据库可以读取时,要实现读操作的负载均衡,可以:
(1)创建DNS记录(一般是内网DNS),用一个域名对应多个从数据库的IP,然后在slave配置的HOST选项填入这个域名。
(2)在应用中按一定的策略进行选择,如随机选择。示例代码如下:
MySQL的主从复制步骤中有网络请求,由于网络抖动等原因,从数据库中的数据可能更新得不及时,如果在执行读操作时对数据的实时性有要求,那么就只能读主数据库了。Django的using关键字用于选择指定的数据库,示例代码如下:
>>> Product.objects.all() # 按照前面的配置,这个会读从数据库 >>> Product.objects.using('default').all() # 指定读取主数据库
3.5.3 垂直分库
值得注意的是,只有在处理大型数据集时,分库/分表才有意义。如果数据的行数少于一百万或只有数千条记录,分库/分表除了增加系统复杂性外,没有任何意义。
比较常见的一种做法是将不同的模块放在不同的数据库中。在我们前面的电商例子中,可以将用户、商品、订单数据分别放在不同的数据库中,如图3.4所示。
图3.4 将单个数据库拆成多个
同读写分离一样,Django可以通过路由将不同模块的请求转到不同的数据库中。首先在settings.py中配置多个数据库,代码如下:
接下来配置路由类,在Django实际使用调用路由类的方法进行路由时,会传入使用的模型,我们根据模型的不同请求路由到不同的数据库,以读操作为例,代码如下:
接下来修改settings.py:
DATABASE_ROUTERS = ['path.to.MultiDatabaseRouter']
同样地,也可以使用using关键字使Django访问不同的数据库。
需要说明的是,将单个数据库实例拆分为多个后,之前单库中能用到的SQL join一般无法继续使用。如果可以调整垂直分库的设计,则优先考虑在设计上解决这个问题。如果不行,则一般有下面的两个实践。
- 全局表。这些表往往是所有模块都会用到的,如“字典表”。在这种情况下,可以在所有的数据库中都设置一份这样的全局数据。
- 字段冗余。这个方法一般用来避免join查询,在这里也可以使用。例如,购物篮数据中除了放用户的ID,还放置用户名字符串。在多数据库的情况下,采用这个方法可能会遇到数据不一致的情况,需要根据业务定期做检查。
3.5.4 水平扩展
随着业务的增长,有可能会出现单个数据表,或者单个数据库无法存下业务数据的现象。例如,用户数量达到了一亿,或者新增订单数量每天超过三百万。在这种情况下,MySQL单个数据表或者单个数据库就无法容纳全部的用户数据库和订单数据了。
在部分达到了这个业务规模的企业中,对数据进行分片是解决这个问题常用的方法之一。这种实践将数据库中的数据进行水平分区,每个单独的分区称为分片。每个分片都保存在单独的数据库实例上,以分散负载。水平分片如图3.5所示。
图3.5 水平分片
水平分区方法有许多的优点,主要如下:
- 由于数据表被分割并分布到多个数据库服务器中,因此数据库中的表的总行数减少了,索引的大小也就减少了,从而提升查询性能。
- 数据库分片可以放在单独硬件上,多个分片可以放在多台机器上,这样会大幅提高性能。
不过在实践中,水平扩展数据是很困难的。实现的方法和使用的数据库类型相关,也和数据本身的特点相关。
分片会给应用程序增加额外的复杂度,同时增加的机器也会增加运维的复杂度。在决定采用这个策略时,要慎重考虑。
常见的分片方法有算法分片和动态分片。算法分片即数据通过某种算法写入不同的分片,在这种策略下,客户端可以在没有其他帮助的情况下确定数据库分区。采用动态分片时,客户端需要先读其他存储,以获取分片信息。
3.5.5 算法分片
算法分片比较简单的一个例子是使用取模算法,例如,使用数据的ID字段,对3进行取模运算,这样就能知道连接哪个数据库。这种做法的思想是将数据分成3份存储,如图3.6所示。
图3.6 取模算法分片
考虑到分片的数量已经确定,在实现的时候可以将所有的分片模型写下来。在读写数据库的时候确认模型,示例代码如下:
按照设计,我们对产品数据的ID取模后将数据划分到不同的数据库中。对3取模后的结果始终只有0、1、2。这里我们根据计算的结果划分模型,分别取名为ProductShard0、ProductShard1、ProductShard2,分别代表了不同数据库中的Product表。为了方便管理,这3个表都命名为product,通过Meta类的db_table来设置表名。
同时还需要配置3个数据库的连接信息,在settings.py中的DATABASES配置,加上3个分片的数据库信息,在3.1节已经讲解过相关的内容,这里暂时省略。
现在我们要让Django将模型和数据库联系起来,实现代码查询需要编写路由类,示例代码如下:
在业务代码中,需要根据产品ID来确定使用哪个模型,确定模型后,Django会自动将请求导向正确的数据库。示例代码如下:
类似地,也可以使用using关键字来完成分片功能,这部分留给读者练习。
通常来说,使用MySQL对数据进行分片,应用程序很难做到完全无感知。在具体实现时,会对业务代码进行一些调整以找到正确的数据源。业务开发者希望调用统一的API来操作数据库而不用考虑数据库的实际部署状况,这依赖于数据库中间件服务,我们会在后面章节谈到这个话题。
3.5.6 动态分片
在动态分片中,需要额外的定位服务来寻找数据源的位置。这种定位服务有多种方式可以实现。如果分区的数量较少,则可以为每一个分区指定数据源;如果分区数量分多,则应该为某个范围的分区指定数据源。动态分片如图3.7所示。
图3.7 动态分片
在客户端读写数据前,需要先定位到数据的位置,然后进行操作。动态分片使得数据的分布更有弹性。不过这种策略实现起来很困难,会遇到客户端和定位服务数据不一致、定位数据更新、定位服务单点故障等问题。在决定采用动态分区前,一定要切合业务,考虑到各种情况。
试想一下,定位服务发生了错误,导致应用从错误的数据源中获取了数据,这对业务的影响将是灾难性的。例如,小明请求自己账户余额,却拿到了小红的账户余额数据;小刚想买1000元的电子设备,花的却是小强的钱。这种后果对于企业来说是不可接受的,因此在构建定位服务时往往选择高一致性的解决方案。
定位服务往往会用到共识算法和同步复制技术存储数据。在大多数情况下,定位的数据是很小的,因此计算成本很低。有一些数据库已经有了这方面的成熟方案。
MongoDB就是这样一个流行的数据库。在MongoDB中,ConfigServer存储分片的信息,mongos执行查询路由。集群内存在多个ConfigServer,多个ConfigServer之间通过同步复制来确保一致性。当一台ConfigServer丢失冗余时,它会进入只读状态。MongoDB的工作过程如图3.8所示。
图3.8 MongoDB的工作过程
图3.8中涉及3个组件:分片、配置服务和路由服务。
分片用于存储数据,我们将数据分为3片,它们提供了高可用性和数据一致性。在生产环境中,每个分片都是一个单独的副本集。
配置服务用于存储集群的元数据,这些数据包含集群数据集到分片的映射。数据路由服务使用此元数据将操作定位到特定分片。在生产环境中为了保证高可用性,往往会配置3台ConfigServer。
mongos服务用于响应客户端的请求并对分片直接操作,最后将结果返回给客户端。一般情况下,mongos也要部署多台以保证高可用性,不过为了让示意图更加简单,图3.8中只涉及一个mongos服务。
实现并维护动态分片是一件非常困难的事情。作出的决策和实际业务数据是紧密关联的,并没有一个通用的方案。要想详细地论证这个话题,需要较多的篇幅来讨论,因此这里只提供一个大致的思路和工业界的实现例子,具体实践需要读者自己探索。
3.5.7 全局ID
在开发Web应用时,为资源生成一个唯一标识符是非常重要的,如用户的ID,这个标识符能帮助我们在系统中定位某一个资源。在使用单实例的MySQL时,可以使用自增的ID作为主键。
但是在数据分片的情况下,每个数据库表都有与其他表隔离的自增ID,如果继续使用自增ID,就可能在查询中出现问题。例如,分片1和分片2的商品表中都存在ID为955的数据,那么查找ID为955的数据时,到底应该以哪一条数据为准呢?
在数据分片的情况下,想要准确定位到某一条数据,就需要为数据生成一个全局唯一的标志(ID)。现在流行的算法是Twitter公司的开源算法——Snowflake算法。
Snowflake算法用于生成唯一的ID。使用这个算法生成的ID是唯一的64位无符号整数,这个数是基于时间戳算出来的。完整的ID由时间戳、机器标识符和序列号组成。
这64位中,第1位设置为0,后面41位是当前时间戳(精确到ms),接下来的10位为机器ID,最后12位为序列号。一个简单的实现例子如下:
使用这个算法可以保证,在一个机房的一台机器上,在同一时间内,生成了一个唯一的ID。如果同一时间内生成了多个ID,则可以用ID的最后12位来区分这多个ID。