1. 简介
- 项目中本身具有非常多的数据库表
- 随着项目的开发,数据库表和数据量也都会增多
- 对数据库进行性能优化
1.1 复制集
- 复制集(Replication)
- 数据库中数据相同,起到备份作用
- 高可用 High Available HA
1.2 分布式
- 分布式(Distribution)
- 数据库中数据不同,共同组成完整的数据集合
- 通常每个节点被称为一个分片(shard)
- 高吞吐 High Throughput
- 复制集与分布式可以单独使用,也可以组合使用(即每个分片都组建一个复制集)
1.3 主从
- 关于主(Master)从(Slave)
- 这个概念是从使用的角度来阐述问题的
- 主节点 -> 表示程序在这个节点上最先更新数据
- 从节点 -> 表示这个节点的数据是要通过复制主节点而来
- 复制集 可选 主从、主主、主主从从
- 分布式 每个分片都是主,组合使用复制集的时候,复制集的是从
2. 复制
1.1 简介
1. 定义
也叫主从同步,数据备份,是一个异步的复制过程
- 在两台数据库服务器的基础上实现了 读写分离,把两台数据库服务器分为一台主服务器(master)和一台从服务器(slave),一台主服务器对应一台从服务器。
- master只负责写入(write)数据,从服务器只负责 同步 主服务器的数据,并让外部程序读取(read)数据也可以让外部程序读取数据(master也可以让外部程序读取数据)
2. 本质
slave从master获取Binary log,然后再在自己身上完全顺序的执行日志中所记录的各种操作
MySQL服务器之间的主从同步是基于二进制日志机制
,主服务器使用二进制日志来记录数据库的变动情况,从服务器通过读取和执行该日志文件来保持和主服务器的数据一致。
3. 原理
- 当master一有数据写入,slave的I/O thread连接上master,并请求读取指定日志文件(Binary log)的指定位置之后的日志内容
- master接收来自slave的IO thread的请求后,让负责复制的I/O thread通过,根据请求信息读取日志信息(Binary log),返回给slaveI/O thread
- slave的IO thread接收到信息后,将接收到的日志内容(数据写入的操作)依次写入slave的Relay log
- slave的SQL thread检测到Relaylog新增加内容后,会马上解析该文件的内容,并在自身执行原始SQL语句(数据写入的操作)
复制分成三步:
- master将改变记录到二进制日志(binary log)中(这些记录叫做二进制日志事件,binary log events);
- slave将master的binary log events拷贝到它的中继日志(relay log);
- slave重做中继日志中的事件,将改变反映它自己的数据。
下图描述了这一过程:
-
该过程的第一部分就是master记录二进制日志。在每个事务更新数据完成之前,master在二日志记录这些改变。MySQL将事务串行的写入二进制日志,即使事务中的语句都是交叉执行的。在事件写入二进制日志完成后,master通知存储引擎提交事务。
-
下一步就是slave将master的binary log拷贝到它自己的中继日志。首先,slave开始一个工作线程——I/O线程。I/O线程在master上打开一个普通的连接,然后开始binlog dump process。Binlog dump process从master的二进制日志中读取事件,如果已经跟上master,它会睡眠并等待master产生新的事件。I/O线程将这些事件写入中继日志。
-
SQL slave thread处理该过程的最后一步。SQL线程从中继日志读取事件,更新slave的数据,使其与master中的数据一致。只要该线程与I/O线程保持一致,中继日志通常会位于OS的缓存中,所以中继日志的开销很小。
-
此外,在master中也有一个工作线程:和其它MySQL的连接一样,slave在master中打开一个连接也会使得master开始一个线程。
-
利用主从在达到高可用的同时,也可以通过读写分离提供吞吐量。
-
读写分离对事务是否有影响
对于写操作包括开启事务和提交或回滚要在一台机器上执行,分散到多台master执行后数据库原生的单机事务就失效了。
对于事务中同时包含读写操作,与事务隔离级别设置有关,如果事务隔离级别为read-uncommitted 或者 read-committed,读写分离没影响,如果隔离级别为repeatable-read、serializable,读写分离就有影响,因为在slave上会看到新数据,而正在事务中的master看不到新数据。
3. 作用
-
对数据进行备份,也就是主从同步后, 当主服务器宕机后,可以从从服务器中选一台当主服务器,提高可用性;当从服务器宕机后,不会有任何影响,体现了 高可用,数据安全
可以增加从服务器来提高数据库的读取性能 -
读写分离实现后,有两台服务器,分摊了读取数据库服务器的压力,提高了吞吐量,实现了高性能
5. 常用架构
5.1 主从架构
1. 简介
在多加几台数据库服务器的基础上实现了读写分离,把多态数据库服务器分为一台主服务器(master)和多台从服务器(slave),master负责write操作,slave负责read操作,一台主服务器对应多台从服务器。
2. 原理
2. 优缺点
优点:
- 一主多从,从库高可用HA,数据安全
- 读写分离,提高了吞吐量,实现了高性能
缺点: - 主库单点,没有实现高可用HA一旦挂了,无法写入
3. 应用场景
微博:微博写微博和读微博的人比例大概是1:10
5.2 主备架构
1. 简介
实质就是开多个数据库服务器,都是master,都可以writer和read,一旦主库挂了,就启用备库
2. 原理
3. 优缺点
优点:
- 高可用,数据安全
缺点: - 单库读写,性能一般
4. 应用场景
阿里云,美团大企业,性能可以通过多个服务器来解决
5. 问题
既然主备互为备份,为什么不采用双主方案,提供两台Master进行负载均衡
- 因为有延迟,会出现脏数据,数据不一致
- 虽然两边执行的修改有先后顺序,但由于 Replication 是异步的实现机制,同样可能导致晚做的修改被做的修改所覆盖
- 不仅B库数据错误,且A&B库数据不一致
- 主备架构搭建除了配置双主同步,还需要配置第三故障转移/高可用方案
5.3 高可用复合架构
1. 简介
在主从架构的基础上,进行主库的备份:主从架构+主备架构
2. 原理
3. 优缺点
- 读写分离,提高吞吐量
- 主从库实现了高可用HA:主库宕机后,去找从库,同理,从库宕机,去找主库
- 提高了吞吐量
A库宕机的情况:
2. 读写分离
2.1 Django实现MySQL读写分离
1. Docker安装运行MySQL从机
提示:
- 搭建
一主一从
的主从同步。 - 主服务器:ubuntu操作系统中的MySQL。
- 从服务器:Docker容器中的MySQL。
1.获取MySQL镜像
- 主从同步尽量保证多台MySQL的版本相同或相近。
$ sudo docker image pull mysql:5.7.22
或
$ sudo docker load -i 文件路径/mysql_docker_5722.tar
2.指定MySQL从机配置文件
- 在使用Docker安装运行MySQL从机之前,需要准备好从机的配置文件。
- 为了快速准备从机的配置文件,直接把主机的配置文件拷贝到从机中。
$ cd ~
$ mkdir mysql_slave
$ cd mysql_slave
$ mkdir data
$ cp -r /etc/mysql/mysql.conf.d ./
3.修改MySQL从机配置文件
- 编辑 ~/mysql_slave/mysql.conf.d/mysqld.cnf文件。
- 由于主从机都在同一个电脑中,所以选择使用不同的端口号区分主从机,从机端口号是8306。
# 从机端口号
port = 8306
# 关闭日志
general_log = 0
# 从机唯一编号
server-id = 2
4.Docker 安装运行 MySQL 从机
- MYSQL_ROOT_PASSWORD:创建 root 用户的密码为 123456。
$ sudo docker run --name mysql-slave -e MYSQL_ROOT_PASSWORD=123456 -d --network=host -v /home/ubuntu/mysql_slave/data:/var/lib/mysql -v /home/ubuntu/mysql_slave/mysql.conf.d:/etc/mysql/mysql.conf.d mysql:5.7.22
5.测试从机是否创建成功
$ mysql -uroot -p123456 -h127.0.0.1 --port=8306
2. 主从同步实现
1.配置主机( ubuntu 中 MySQL)
- 配置文件如有修改,需要重启主机。
sudo service mysql restart
首先, 进入主机的配置文件所在地:
cd /etc/mysql/mysql.conf.d/
进入后找到 mysqld.cnf 文件, 对其进行修改:
sudo vim mysqld.cnf
修改内容如下所示:
# 开启日志: 把下面的代码注释去掉
general_log_file = /var/log/mysql/mysql.log
general_log = 1
# 主机唯一编号
server-id = 1
# 二进制日志文件
log_bin = /var/log/mysql/mysql-bin.log
2.从机备份主机原有数据
- 在做主从同步时,如果从机需要主机上原有数据,就要先复制一份到从机。
# 1. 收集主机原有数据
$ mysqldump -uroot -pmysql --all-databases --lock-all-tables > ~/master_db.sql
# 2. 从机复制主机原有数据
$ mysql -uroot -p123456 -h127.0.0.1 --port=8306 < ~/master_db.sql
3.主从同步实现
1.创建用于从服务器同步数据的帐号
# 登录到主机
$ mysql –uroot –pmysql
# 创建从机账号
$ GRANT REPLICATION SLAVE ON *.* TO 'slave'@'%' identified by 'slave';
# 刷新权限
$ FLUSH PRIVILEGES;
2.展示 ubuntu 中 MySQL 主机的二进制日志信息
$ SHOW MASTER STATUS;
3.Docker 中 MySQL 从机连接 ubuntu 中 MySQL 主机
# 登录到从机
$ mysql -uroot -p123456 -h 127.0.0.1 --port=8306
# 从机连接到主机
$ change master to master_host='127.0.0.1', master_user='slave', master_password='slave',master_log_file='mysql-bin.000250', master_log_pos=990250;
# 开启从机服务
$ start slave;
# 展示从机服务状态
$ show slave status \G;
测试:
在主机中新建一个数据库后,直接在从机查看是否存在。
- 增加slave数据库的配置
DATABASES = {
'default': { # 写(主机)
'ENGINE': 'django.db.backends.mysql', # 数据库引擎
'HOST': '172.16.238.128', # 数据库主机
'PORT': 3306, # 数据库端口
'USER': 'root', # 数据库用户名
'PASSWORD': 'mysql', # 数据库用户密码
'NAME': 'project' # 数据库名字
},
'slave': { # 读(从机)
'ENGINE': 'django.db.backends.mysql',
'HOST': '172.16.238.128',
'PORT': 8306,
'USER': 'root',
'PASSWORD': '123456',
'NAME': 'project'
}
}
- 创建和配置数据库读写路由
- 创建数据库读写路由
- 在mall.utils.db_router.py中实现读写路由
class MasterSlaveDBRouter(object):
"""数据库读写路由"""
def db_for_read(self, model, **hints):
"""读所使用的服务器:"""
return "slave"
def db_for_write(self, model, **hints):
"""写所使用的服务器:"""
return "default"
def allow_relation(self, obj1, obj2, **hints):
"""是否运行关联操作"""
return True
- 配置数据库读写路由
- 在 dev.py 文件中配置如下参数
DATABASE_ROUTERS = ['meiduo_mall.utils.db_router.MasterSlaveDBRouter']
2.2 falsk实现读写分离
- 需求分析:
sqlchemy
并没有像django-orm
一样内置完善的读写分离方案,但是提供了可以自定义的接口:我们可以借此对flask-sqlchemy
进行二次开发,实现读写分离 - 思路分析:
- 实现自定义的
session
类(SignallingSession),继承SignllingSession
类- 重写`get_bind方法,根据读写需求选择对应的数据库地址
- 实现自定义的
SQLAlchemy
类,继承与SQLAlchemy
类- 重写
create_session
方法,在内部实现自定义的Session
类
- 重写
- 实现自定义的
- 虚拟机搭建好Mysql主从,可以直接用于测试使用
- 主数据库端口 3306
- 从数据库接口 8306
2.2 项目集成
- 将工具包
routting_db
导入common/models
中,其中的`routing_sqlchemy.py文件实现了读写分离
import random
from flask import Flask
from flask_sqlalchemy import SQLAlchemy, SignallingSession, get_state
import pymysql
from sqlalchemy import orm
pymysql.install_as_MySQLdb()
app = Flask(__name__)
# 单数据库
app.config["SQLALCHEMY_DATABASE_URI"] = "mysql://root:mysql@192.168.243.151:3306/test30"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
# 多数据库-主从
app.config["SQLALCHEMY_BINDS"] = {
"master": "mysql://root:mysql@192.168.243.151:3306/test30",
"slave1": "mysql://root:mysql@192.168.243.151:8306/test30",
"slave2": "mysql://root:mysql@192.168.243.151:3306/test30",
}
# 1. 自定义Session类, 继承SignallingSession, 并重写get_bind方法
class RoutingSession(SignallingSession):
def __init__(self, *args, **kwargs):
super(RoutingSession, self).__init__(*args, **kwargs)
def get_bind(self, mapper=None, clause=None):
"""每次数据库操作(增删改查及事务操作)都会调用该方法, 来获取对应的数据库引擎(访问的数据库)"""
state = get_state(self.app)
if mapper is not None:
try:
# SA >= 1.3
persist_selectable = mapper.persist_selectable
except AttributeError:
# SA < 1.3
persist_selectable = mapper.mapped_table
# 如果项目中指明了特定数据库,就获取到bind_key指明的数据库,进行数据库绑定
info = getattr(persist_selectable, 'info', {})
bind_key = info.get('bind_key')
if bind_key is not None:
return state.db.get_engine(self.app, bind=bind_key)
# 使用默认的主数据库
# return SessionBase.get_bind(self, mapper, clause)
from sqlalchemy.sql.dml import UpdateBase
# 如果模型类未指定数据库, 判断是否为写操作
# delete和update不会触发_flushing
# isinstance(clause, UpdateBase) 判断数据库操作行为,clause如果是增删改查都是属于UpdateBase子类
if self._flushing or isinstance(clause, UpdateBase):
# 写操作--主数据库
print("写操作--主数据库")
return state.db.get_engine(self.app, bind="master")
else:
# 读操作--从数据库
slave_key = random.choice(["slave1", "slave2"])
print("读操作--从数据库: ", slave_key)
return state.db.get_engine(self.app, bind=slave_key)
# 2. 自定义SQLALchemy类, 重写create_session方法
class RoutingSQLAlchemy(SQLAlchemy):
def create_session(self, options):
# 继承-拓展SQLAlchemy的功能,封装一个RoutingSession类实现读写分离
return orm.sessionmaker(class_=RoutingSession, db=self, **options)
# 自定义RoutingSQLAlchemy类创建数据库对象
db = RoutingSQLAlchemy(app)
# 构建模型类
class User(db.Model):
__tablename__ = 't_user'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(20), unique=True)
age = db.Column(db.Integer, default=0, index=True)
@app.route('/')
def index():
"""增加数据"""
# read()
# write()
# read()
# update()
return "index"
def read():
print('---读-----------')
users = User.query.all()
print(users)
for user in users:
print(user.id, user.name, user.age)
def write():
print('---写-----------')
user1 = User(name='james', age=20)
db.session.add(user1)
db.session.commit()
def update():
print("---更新写---")
User.query.filter(User.name == 'xiaoming').update({"name": "Uzi"})
db.session.commit()
if __name__ == '__main__':
# 重置所有继承自db.Model的表
# 如果模型类没有设置__bind_ky__属性(指定对应的数据库), 则DDL操作 根据SQLALCHEMY_DATABASE_URI 指定的数据库进行处理
# db.drop_all()
# db.create_all()
app.run(debug=True, port=8888)
- 在
app/settings/config.py
文件中设计值主从数据库的URL地址
# app/settings/config.py
class DefaultConfig:
"""默认配置"""
SQLALCHEMY_BINDS = { # 主从数据库的URI
"master": 'mysql://root:mysql@192.168.105.140:3306/hm_topnews',
"slave1": 'mysql://root:mysql@192.168.105.140:3306/hm_topnews',
"slave2": 'mysql://root:mysql@192.168.105.140:8306/hm_topnews'
}
- 在
app/__init__.py
文件中使用自定义SQLAchemy类
# app/__init__.py
# from flask_sqlalchemy import SQLAlchemy
# db = SQLAlchemy()
from models.routing_db.routing_sqlalchemy import RoutingSQLAlchemy
# mysql数据库操作对象
db = RoutingSQLAlchemy()
2. 分片(sharding)
2.1 简介
1. 分库分表前的问题
任何问题都是太大或者太小的问题,这里面对的数据量太大的问题。
-
用户请求量太大
因为单服务器TPS,内存,IO都是有限的。 解决方法:分散请求到多个服务器上; 其实用户请求和执行一个sql查询是本质是一样的,都是请求一个资源,只是用户请求还会经过网关,路由,http服务器等。
-
单库太大
单个数据库处理能力有限;单库所在服务器上磁盘空间不足;单库上操作的IO瓶颈 解决方法:切分成更多更小的库
-
单表太大
CRUD都成问题;索引膨胀,查询超时 解决方法:切分成多个数据集更小的表。
2. 分库分表的方式方法
-
一般就是垂直切分和水平切分,这是一种结果集描述的切分方式,是物理空间上的切分。 从面临的问题,开始解决,阐述: 首先是用户请求量太大,就堆机器搞定
-
然后是单个库太大,这时要看是因为表多而导致数据多,还是因为单张表里面的数据多。 如果是因为表多而数据多,使用垂直切分,根据业务切分成不同的库。
-
如果是因为单张表的数据量太大,这时要用水平切分,即把表的数据按某种规则切分成多张表,甚至多个库上的多张表。 分库分表的顺序应该是先垂直分,后水平分。 因为垂直分更简单,更符合处理现实世界问题的方式。
3. 分片简介
-
需求分析:
- 用户请求量太大,会导致web应用无法及时响应->分布式服务器(分散请求到多个服务器上)
- 表单太大,会导致CRUD都成问题,索引膨胀,查询超时->拆分表
- 单库太大,会导致单库磁盘空间不足:处理能力有限,出现IO瓶颈->拆分库
-
作用
- 分片也成为数据拆分(Shareding),其主要工作就是对单库单表进行拆分,多苦多表共同组成完整的数据集合
- 分片可以提高吞吐量,同一时间数据的读写完成量更多,扩充单机存储量的容量/读写速度上限
-
分类
- 垂直拆分:字段太多
- 水平拆分
- 使用频率(常用字段/不常用字段)
- 垂直分库,分表–blind–key–来实现,修改数据库
- HASH取模 离散化
去用户id,然后hash取模,飞陪到不同的数据库上,遮掩
-
注意点
-
不要轻易分库分表,因为分片会带来 诸多分布式问题, 让应用的复杂度大量增加
-
应避免"过度设计"和"过早优化", 先尽力去做其他优化,例如:升级硬件、升级网络、读写分离、索引优化、缓存设计等等。
-
当数据量达到单表瓶颈时候(参考值: 单表记录1000W+/硬盘100G+),再考虑分库分表
-
如果需要进行分库分表, 优先考虑垂直拆分
* 地理区域 分布式问题 * 分布式事务 * 跨Join/排序/分页 方案一: * 不需要分 方案二: * 二阶段事务session_options = {“twoparse} * begin:xl * prepare:二阶段预提交 * commit:真正提交 方案三: * ebay * 状态字段 * 1. 表中定义状态字段 2. 两个系统定义 ***消息接口*** 3.
-
分库访问
- flask-sqlalchemy通过
-
水平拆分:记录太多
3. 垂直拆分
3.1 垂直分表
1. 简介
- 也就是“大表拆小表”,基于列字段进行的。一般是表中的字段较多,将不常用的, 数据较大,长度较长(比如text类型字段)的拆分到“扩展表“。 一般是针对那种几百列的大表,也避免查询时,数据量太大造成的“跨页”问题。
- 按 字段 将一张表拆分成多张表
- 对于字段较多的表, 每条记录占用的空间也会较多, 导致每次从硬盘中读取的记录以及查询缓存可缓存的记录数量较少, 影响查询查询效率
- 针对字段多的表就可以采用垂直分表来进行拆分, 这样可以减少表体积, 提高查询效率
2. 拆分规则
- 相关性
- 可以将字段根据 业务逻辑 和 使用的相关性 进行分表划分
- 如: 用户名和密码经常配合使用, 将其分到用户认证表, 生日和邮箱等个人信息经常一起访问, 将其分到用户信息表
- 使用频率
- 可以将字段根据 常用 和 不常用 进行划分, 并进行分表处理
- 如: 原始用户表中包含了多个字段, 其中有常用的昵称、手机号等字段, 也包含不常用的邮箱、生日等字段, 可以根据使用频率将其分为两张表: 用户基础信息表 和 用户其他信息表
- 项目中的应用
- 用户数据垂直分表
user_basic
&user_profile
- 文章数据垂直分表
article_basic
&article_content
(文章内容较长且只在详情页才需要)
- 用户数据垂直分表
3.2 垂直分库
1. 简介
-
垂直分库针对的是一个系统中的不同业务进行拆分,比如用户User一个库,商品Producet一个库,订单Order一个库。 切分后,要放在多个服务器上,而不是一个服务器上。为什么? 我们想象一下,一个购物网站对外提供服务,会有用户,商品,订单等的CRUD。没拆分之前, 全部都是落到单一的库上的,这会让数据库的单库处理能力成为瓶颈。按垂直分库后,如果还是放在一个数据库服务器上, 随着用户量增大,这会让单个数据库的处理能力成为瓶颈,还有单个服务器的磁盘空间,内存,tps等非常吃紧。 所以我们要拆分到多个服务器上,这样上面的问题都解决了,以后也不会面对单机资源问题。
-
数据库业务层面的拆分,和服务的“治理”,“降级”机制类似,也能对不同业务的数据分别的进行管理,维护,监控,扩展等。 数据库往往最容易成为应用系统的瓶颈,而数据库本身属于“有状态”的,相对于Web和应用服务器来讲,是比较难实现“横向扩展”的。 数据库的连接资源比较宝贵且单机处理能力也有限,在高并发场景下,垂直分库一定程度上能够突破IO、连接数及单机硬件资源的瓶颈。
- 将一个数据库中的多张表拆分到多个数据库(服务器节点)中
- 注意点:
- 由于 本地事务不支持跨库操作, 所以应该将 有相关联性的表放在同一个库中
- 如: 如果后续项目垂直分库, 将用户相关的放在数据库1, 文章相关的放在数据库2
# 默认
数据库 t_user t_article
# 垂直分表
数据库 t_user_basic t_user_detail t_article_basic t_article_detail
# 垂直分库
数据库1 t_user_basic t_user_detail
数据库2 t_article_detail t_article_basic
3.3 分库访问
-
flask-sqlalchemy
通过配置SQLALCHEMY_BINDS
允许设置多个数据库URI, 并且每个模型类可以__bind_key__
属性 设置自己对应访问的数据库 -
示例场景如下: 项目进行了分库处理, 包含两个库 db1 和 db2, 用户表t_user存储在db1中, 而地址表t_adr存储在db2中
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
# 设置多个数据库地址 (用于数据操作)
app.config['SQLALCHEMY_BINDS'] = {
'db1': 'mysql://root:mysql@192.168.105.140:3306/db1',
'db2': 'mysql://root:mysql@192.168.105.140:3306/db2'
}
# 其他配置
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True
app.config['SQLALCHEMY_ECHO'] = True
# 创建组件对象
db = SQLAlchemy(app)
# 用户表 存储在db1中
class User(db.Model):
__tablename__ = 't_user'
__bind_key__ = 'db1' # 设置表所在的数据库URI
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(20))
# 地址表 存储在db2中
class Address(db.Model):
__tablename__ = 't_adr'
__bind_key__ = 'db2' # 设置表所在的数据库URI
id = db.Column(db.Integer, primary_key=True)
detail = db.Column(db.String(20), unique=True)
user_id = db.Column(db.Integer)
@app.route('/')
def index():
"""添加数据"""
user1 = User(name='张三')
db.session.add(user1)
db.session.flush()
adr1 = Address(detail='中关村3号', user_id=user1.id)
adr2 = Address(detail='华强北5号', user_id=user1.id)
db.session.add_all([adr1, adr2])
db.session.commit() # 虽然只调用一次commit, 但由于需要到两个数据库进行操作, 其实是两个数据库分别创建一个事务并提交
return "index"
@app.route('/demo1')
def demo1():
"""查询多表数据"""
user1 = User.query.filter_by(name='张三').first()
adrs = Address.query.filter_by(user_id=user1.id).all()
for adr in adrs:
print(adr.detail)
return 'demo1'
if __name__ == '__main__':
# 重置所有继承自db.Model的表
db.drop_all()
db.create_all()
app.run(debug=True)
4. 水平拆分
4.1 水平分表
- 将 一张表的记录 拆分到多张表中
- 对于记录较多的表, 会出现 索引膨胀, 查询超时 等问题, 影响用户体验
- 针对数据量巨大的单张表(比如订单表),按照某种规则(RANGE,HASH取模等),切分到多张表里面去。 但是这些表还是在同一个库中,所以库级别的数据库操作还是有IO瓶颈。不建议采用。
4.2 水平分库分表
- 水平分表后, 将分表分散放在多个数据库节点中
- 将单张表的数据切分到多个服务器上去,每个服务器具有相应的库与表,只是表中数据集合不同。 水平分库分表能够有效的缓解单机和单库的性能瓶颈和压力,突破IO、连接数、硬件资源等的瓶颈。
4.3 水平分库分表拆分规则
-
ID范围/RANGE
- 从0到10000一个表,10001到20000一个表;
- 从 0 到 100W 一个表,100W+1 到 200W 一个表。
-
HASH取模 离散化
- 取用户id,然后hash取模,分配到不同的数据库上。这样可以同时向多个表中插入数据, 提高并发能力, 同时由于用户id进行了离散处理, 不会出现ID冲突的问题
- 一个商场系统,一般都是将用户,订单作为主表,然后将和它们相关的作为附表,这样不会造成跨库事务之类的问题。 取用户id,然后hash取模,分配到不同的数据库上。
-
业务
- 按照业务将数据进行分类并拆分, 如文章包含金融、科技等多个分类, 可以每个分类的数据拆分到一张表中。
-
地理区域
比如按照华东,华南,华北这样来区分业务,七牛云等云服务应该就是如此。
-
时间
按照时间切分,就是将6个月前,甚至一年前的数据切出去放到另外的一张表,因为随着时间流逝,这些表的数据 被查询的概率变小,所以没必要和“热数据”放在一起,这个也是“冷热数据分离”。
4.4 数据库定向查询
- 如果进行了水平拆分, 在没有精确过滤条件的情况下, 可能需要到多个数据库中依次查询目标数据
*可以对RoutingSession
进行二次开发, 提供方法进行 数据库定向查询 - 应用场景如下: 对用户表进行水平分库分表, 用户数据分别保存在
db1.t_user
和db2.t_user
中, 项目的其他数据保存在数据库test
中
import random
from flask import Flask
from flask_sqlalchemy import SQLAlchemy, SignallingSession, get_state
from sqlalchemy import orm
app = Flask(__name__)
# 设置单个数据库URI (用于建表并添加测试数据)
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://root:mysql@192.168.105.140:3306/db1'
# 设置多个数据库的URI (用于数据操作)
app.config['SQLALCHEMY_BINDS'] = {
'db1': 'mysql://root:mysql@192.168.105.140:3306/db1',
'db2': 'mysql://root:mysql@192.168.105.140:3306/db2',
'master': 'mysql://root:mysql@192.168.105.140:3306/test',
'slave': 'mysql://root:mysql@192.168.105.140:3306/test'
}
# 其他配置
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SQLALCHEMY_ECHO'] = True
# 1. 自定义Session类, 继承SignallingSession, 并重写get_bind方法
class RoutingSession(SignallingSession):
def get_bind(self, mapper=None, clause=None):
"""每次数据库操作(增删改查及事务操作)都会调用该方法, 来获取对应的数据库引擎(访问的数据库)"""
state = get_state(self.app)
if self._bind: # 如果查询指定了访问的数据库, 则使用指定的数据库
print('查询数据库:', self._bind)
return state.db.get_engine(self.app, bind=self._bind)
elif mapper is not None: # 如果模型类已指定数据库, 使用指定的数据库
info = getattr(mapper.mapped_table, 'info', {})
bind_key = info.get('bind_key')
if bind_key is not None:
return state.db.get_engine(self.app, bind=bind_key)
if self._flushing: # 如果模型类未指定数据库, 判断是否为写操作
print('写操作')
return state.db.get_engine(self.app, bind='master')
else:
print('读操作')
return state.db.get_engine(self.app, bind='slave')
_bind = None # 定义类属性记录要访问的数据库
def using_bind(self, bind):
"""指定要访问的数据库"""
self._bind = bind
return self
# 2. 自定义SQLALchemy类, 重写create_session方法
class RoutingSQLAlchemy(SQLAlchemy):
def create_session(self, options):
return orm.sessionmaker(class_=RoutingSession, db=self, **options)
# 创建组件对象
db = RoutingSQLAlchemy(app)
# 构建模型类
class User(db.Model):
__tablename__ = 't_user'
id = db.Column(db.Integer, primary_key=True)
name = db.Column('username', db.String(20), unique=True)
age = db.Column(db.Integer, default=0, index=True)
@app.route('/')
def index():
for db_bind in ['db1', 'db2']: # 遍历各数据库节点, 查询用户数据
user = db.session().using_bind(db_bind).query(User).filter(User.name == 'zs').first()
print(user)
if user:
print(user.id, user.name, user.age)
return "index"
if __name__ == '__main__':
# 重置所有继承自db.Model的表
db.drop_all()
db.create_all()
# 添加测试数据 需要分别往db1和db2中添加一条数据
user1 = User(name='zs', age=20)
db.session.add(user1)
db.session.commit()
app.run(debug=True)
5. 分布式问题
5.1 分布式失去问题
- 事务支持
- 分库分表后,就成了分布式事务了。如果依赖数据库本身的分布式事务管理功能去执行事务,将付出高昂的性能代价;
- 如果由应用程序去协助控制,形成程序逻辑上的事务,又会造成编程方面的负担。
- 本地事务不支持跨库操作
- 解决办法从简单到复杂有三种
1. 方案一
- 将有关联的表放在一个数据库中
- 同库操作可以使用一个事务
- 如用户表&用户频道表, 文章基本信息表&文章内容表放在一起
2. 方案二
-
Mysql从5.6开始支持分布式事务
-
核心是二阶段提交协议(简称 2PC协议 / XA协议)
-
分布式事务会提供一个 事务管理器 来对 各数据库的本地事务进行统一管理, 只有各本地事务都向管理器 预提交 成功后, 事务管理器才会统一执行提交处理, 否则统一进行回滚处理
-
sqlalchemy
也支持分布式事务- 只需要在创建
SQLAlchemy
对象时, 设置参数session_options={'twophase': True}
即可
- 只需要在创建
-
设置后, 整个session的所有操作会被放入到一个分布式事务中, 并在整个分布式事务范围内保证原子性
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
# 设置多个数据库地址
app.config['SQLALCHEMY_BINDS'] = {
'db1': 'mysql://root:mysql@192.168.105.140:3306/db1',
'db2': 'mysql://root:mysql@192.168.105.140:3306/db2'
}
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True
app.config['SQLALCHEMY_ECHO'] = True
# 创建组件对象 设置二阶段提交
db = SQLAlchemy(app, session_options={'twophase': True})
# 用户表
class User(db.Model):
__tablename__ = 't_user'
__bind_key__ = 'db1' # 设置数据库db1
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(20))
# 地址表
class Address(db.Model):
__tablename__ = 't_adr'
__bind_key__ = 'db2' # 设置数据库db2
id = db.Column(db.Integer, primary_key=True)
detail = db.Column(db.String(20), unique=True)
user_id = db.Column(db.Integer)
@app.route('/')
def index():
"""添加数据"""
user1 = User(name='张三')
db.session.add(user1)
db.session.flush()
adr1 = Address(detail='中关村3号', user_id=user1.id)
adr2 = Address(detail='华强北5号', user_id=user1.id)
db.session.add_all([adr1, adr2])
db.session.flush()
db.session.commit() # 由于采用了分布式事务, 整个session的操作会被放入到一个分布式事务中, 并实现事务的原子性
return "index"
@app.route('/demo1')
def demo1():
"""查询多表数据 需求: 查询姓名为"张三"的所有地址信息"""
# 先根据姓名查找用户主键
user1 = User.query.filter_by(name='张三').first()
# 再根据主键到从表查询关联地址
adrs = Address.query.filter_by(user_id=user1.id).all()
for adr in adrs:
print(adr.detail)
return 'demo1'
if __name__ == '__main__':
# 删除所有继承自db.Model的表
db.drop_all()
# 创建所有继承自db.Model的表
db.create_all()
app.run(debug=True)
- 注意点:
- 分布式事务要在所有事务都"提交成功"的情况下才会正式提交, 如果参与的部分节点卡顿, 会影响整个事务的性能
3. 方案三
- 基于状态/消息的最终一致性方案
- 对于 包含多个子系统的大型项目, 需要保证子系统之间的数据一致性
- 单个子系统往往不会操作所有数据库, 但是 每个子系统可以通过定义字段来记录操作的状态, 每完成一个阶段则更新相应的状态
- 如下单-付款流程中, 应用A的下单事务完成后更新订单状态为 已下单, 应用B付款事务完成后, 再通过 支付回调接口 通知应用A 更新订单状态
- 应用B还需要提供一个 支付查询接口, 以便在用户查询或者订单超时的情况下, 让应用A可以查询订单的支付情况
- ebay 提出的方案, 理论叫做 BASE
5.2 跨节点 Join/排序/分页
- 跨库join
- 不支持的跨库操作包括join/分组/聚合/排序
1. 方案一
- 分两次查询进行, 在应用端合并
2. 方案二
- 使用一些第三方方案(数据库中间件)
- 开源框架除了Mycat, 功能较少
- 需要一定学习成本, 二次开发需要公司具有一定技术实力
- 以下为推荐的开源框架:
MySQL Router:https://github.com/mysql/mysql-router
Atlas:https://github.com/Qihoo360/Atlas
Mycat:https://github.com/MyCATApache/Mycat-Server - 付费框架推荐: 阿里DRDS
- 功能: 分库分表、分布式JOIN/聚合/排序、分布式事务、平滑扩容、读写分离, 全局唯一ID
- 基础版: 14000+/年
- 一个字: 强!
分库分表后表之间的关联操作将受到限制,我们无法join位于不同分库的表,也无法join分表粒度不同的表, 结果原本一次查询能够完成的业务,可能需要多次查询才能完成。 粗略的解决方法: 全局表:基础数据,所有库都拷贝一份。 字段冗余:这样有些字段就不用join去查询了。 系统层组装:分别查询出所有,然后组装起来,较复杂。
3. 方案三
-
分库分表方案产品
-
目前市面上的分库分表中间件相对较多,其中基于代理方式的有MySQL Proxy和Amoeba, 基于Hibernate框架的是Hibernate Shards,基于jdbc的有当当sharding-jdbc, 基于mybatis的类似maven插件式的有蘑菇街的蘑菇街TSharding, 通过重写spring的ibatis template类的Cobar Client。
-
还有一些大公司的开源产品:
5.3 多库结果集合并(group by,order by)
6. 项目应用
-
主从
-
垂直分表
CREATE TABLE `user_basic` (
`user_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`account` varchar(20) COMMENT '账号',
`email` varchar(20) COMMENT '邮箱',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态,是否可用,0-不可用,1-可用',
`mobile` char(11) NOT NULL COMMENT '手机号',
`password` varchar(93) NULL COMMENT '密码',
`user_name` varchar(32) NOT NULL COMMENT '昵称',
`profile_photo` varchar(128) NULL COMMENT '头像',
`last_login` datetime NULL COMMENT '最后登录时间',
`is_media` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否是自媒体,0-不是,1-是',
`is_verified` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否实名认证,0-不是,1-是',
`introduction` varchar(50) NULL COMMENT '简介',
`certificate` varchar(30) NULL COMMENT '认证',
`article_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '发文章数',
`following_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '关注的人数',
`fans_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '被关注的人数',
`like_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '累计点赞人数',
`read_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '累计阅读人数',
PRIMARY KEY (`user_id`),
UNIQUE KEY `mobile` (`mobile`),
UNIQUE KEY `user_name` (`user_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户基本信息表';
CREATE TABLE `user_profile` (
`user_id` bigint(20) unsigned NOT NULL COMMENT '用户ID',
`gender` tinyint(1) NOT NULL DEFAULT '0' COMMENT '性别,0-男,1-女',
`birthday` date NULL COMMENT '生日',
`real_name` varchar(32) NULL COMMENT '真实姓名',
`id_number` varchar(20) NULL COMMENT '身份证号',
`id_card_front` varchar(128) NULL COMMENT '身份证正面',
`id_card_back` varchar(128) NULL COMMENT '身份证背面',
`id_card_handheld` varchar(128) NULL COMMENT '手持身份证',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`register_media_time` datetime NULL COMMENT '注册自媒体时间',
`area` varchar(20) COMMENT '地区',
`company` varchar(20) COMMENT '公司',
`career` varchar(20) COMMENT '职业',
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户资料表';
CREATE TABLE `news_article_basic` (
`article_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '文章ID',
`user_id` bigint(20) unsigned NOT NULL COMMENT '用户ID',
`channel_id` int(11) unsigned NOT NULL COMMENT '频道ID',
`title` varchar(128) NOT NULL COMMENT '标题',
`cover` json NOT NULL COMMENT '封面',
`is_advertising` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否投放广告,0-不投放,1-投放',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '贴文状态,0-草稿,1-待审核,2-审核通过,3-审核失败,4-已删除',
`reviewer_id` int(11) NULL COMMENT '审核人员ID',
`review_time` datetime NULL COMMENT '审核时间',
`delete_time` datetime NULL COMMENT '删除时间',
`reject_reason` varchar(200) COMMENT '驳回原因',
`comment_count` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '累计评论数',
`allow_comment` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否允许评论,0-不允许,1-允许',
PRIMARY KEY (`article_id`),
KEY `user_id` (`user_id`),
KEY `article_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='文章基本信息表';
CREATE TABLE `news_article_content` (
`article_id` bigint(20) unsigned NOT NULL COMMENT '文章ID',
`content` longtext NOT NULL COMMENT '文章内容',
PRIMARY KEY (`article_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='文章内容表';