🙊
🙊
🙊
🙊
关于作者
搜索文档…
⌃K

关系型数据库基本原理

博客作者:联系请点击,搬运不易,希望请作者喝咖啡,可以点击联系博客作者

前言

本文所述内容主要针对的是关系型数据库,nosql 数据库并不适用。

1. 事务

事务指的是满足 ACID 特性的一组操作,可以通过 Commit 提交一个事务,也可以使用 Rollback 进行回滚。

1.1. ACID

Could not load image
  • 原子性(Automicity)
    • 事务被视为不可分割的最小单元,事务中的所有操作要么全部提交成功,要么全部失败回滚。
    • 回滚可以用日志来实现,日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。
  • 一致性(Consistency)
    • 数据库在事务执行前后都保持一致性状态。
    • 在一致性状态下,所有事务对一个数据的读取结果都是相同的。
  • 隔离性(Isolation)
    • 一个事务所做的修改在最终提交以前,对其它事务是不可见的。
  • 持久性(Durability)
    • 一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。
    • 可以通过数据库备份和恢复来实现,在系统发生奔溃时,使用备份的数据库进行数据恢复。
ACID 小结:
事务的 ACID 特性概念简单,但不是很好理解,主要是因为这几个特性不是一种平级关系:
  • 只有满足一致性,事务的执行结果才是正确的。
  • 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时要只要能满足原子性,就一定能满足一致性。
  • 在并发的情况下,多个事务并发执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
  • 事务满足持久化是为了能应对数据库奔溃的情况。

1.2. 并发一致性问题

在并发环境下,事务的隔离性很难保证,因此会出现很多并发一致性问题。
  • 丢失修改
T1 和 T2 两个事务都对一个数据进行修改,T1 先修改,T2 随后修改,T2 的修改覆盖了 T1 的修改。
T1 修改一个数据,T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据。-
Could not load image
T2 读取一个数据,T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。-
T1 读取某个范围的数据,T2 在这个范围内插入新的数据,T1 再次读取这个范围的数据,此时读取的结果和和第一次读取的结果不同。
Could not load image
产生并发不一致性问题主要原因是破坏了事务的隔离性,解决方法是通过并发控制来保证隔离性。
并发控制可以通过封锁来实现,但是封锁操作需要用户自己控制,相当复杂。数据库管理系统提供了事务的隔离级别,让用户以一种更轻松的方式处理并发一致性问题。

1.3. 事务隔离级别

  • 未提交读(READ UNCOMMITTED) - 事务中的修改,即使没有提交,对其它事务也是可见的。
  • 提交读(READ COMMITTED) - 一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的。
  • 重复读(REPEATABLE READ) - 保证在同一个事务中多次读取同样数据的结果是一样的。
  • 串行化(SERIALIXABLE) - 强制事务串行执行。
隔离级别
脏读
不可重复读
幻影读
未提交读
提交读
重复读
串行化

分布式事务

在单一数据节点中,事务仅限于对单一数据库资源的访问控制,称之为 本地事务。几乎所有的成熟的关系型数据库都提供了对本地事务的原生支持。
分布式事务 是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。

两阶段提交

两阶段提交(XA)对业务侵入很小。 它最大的优势就是对使用方透明,用户可以像使用本地事务一样使用基于XA协议的分布式事务。 XA协议能够严格保障事务 ACID 特性。
严格保障事务 ACID 特性是一把双刃剑。 事务执行在过程中需要将所需资源全部锁定,它更加适用于执行时间确定的短事务。 对于长事务来说,整个事务进行期间对数据的独占,将导致对热点数据依赖的业务系统并发性能衰退明显。 因此,在高并发的性能至上场景中,基于XA协议的分布式事务并不是最佳选择。
柔性事务
如果将实现了ACID的事务要素的事务称为刚性事务的话,那么基于BASE事务要素的事务则称为柔性事务。 BASE是基本可用、柔性状态和最终一致性这三个要素的缩写。
  • 基本可用(Basically Available)保证分布式事务参与方不一定同时在线。
  • 柔性状态(Soft state)则允许系统状态更新有一定的延时,这个延时对客户来说不一定能够察觉。
  • 而最终一致性(Eventually consistent)通常是通过消息传递的方式保证系统的最终一致性。
ACID事务中对隔离性的要求很高,在事务执行过程中,必须将所有的资源锁定。 柔性事务的理念则是通过业务逻辑将互斥锁操作从资源层面上移至业务层面。通过放宽对强一致性要求,来换取系统吞吐量的提升。
基于ACID的强一致性事务和基于BASE的最终一致性事务都不是银弹,只有在最适合的场景中才能发挥它们的最大长处。 可通过下表详细对比它们之间的区别,以帮助开发者进行技术选型。

事务方案对比

本地事务
两(三)阶段事务
柔性事务
业务改造
实现相关接口
一致性
不支持
支持
最终一致
隔离性
不支持
支持
业务方保证
并发性能
无影响
严重衰退
略微衰退
适合场景
业务方处理不一致
短事务 & 低并发
长事务 & 高并发

2. 并发控制

无论何时,只要有多个查询需要在同一时刻修改数据,就会产生并发控制的问题。

2.1. 锁粒度

应该尽量只锁定需要修改的那部分数据,而不是所有的资源。在给定的资源上,锁定的数据量越少,则系统的并发程度越高,只要相互之间不发生冲突即可。
但是加锁需要消耗资源,锁的各种操作,包括获取锁、释放锁、以及检查锁状态等,都会增加系统开销。因此锁粒度越小,系统开销就越大。
所谓锁策略,就是在锁的开销和并发程度之间寻求平衡,这种平衡自然也会影响到性能。
很多数据库都提供了表级锁和行级锁。
  • 表级锁(table lock) - 锁定整张表。用户对表进行写操作前,需要先获得写锁,这会阻塞其他用户对该表的所有读写操作。只有没有写锁时,其他用户才能获得读锁,读锁之间不会相互阻塞。
  • 行级锁(row lock) - 仅对指定的行记录进行加锁,这样其它进程还是可以对同一个表中的其它记录进行操作。

2.2. 锁类型

读写锁
  • 排它锁(Exclusive),简写为 X 锁,又称写锁。
  • 共享锁(Shared),简写为 S 锁,又称读锁。
有以下两个规定:
  • 一个事务对数据对象 A 加了 X 锁,就可以对 A 进行读取和更新。加锁期间其它事务不能对 A 加任何锁。
  • 一个事务对数据对象 A 加了 S 锁,可以对 A 进行读取操作,但是不能进行更新操作。加锁期间其它事务能对 A 加 S 锁,但是不能加 X 锁。
锁的兼容关系如下:
锁类型
X
S
X
S
意向锁
使用意向锁(Intention Locks)可以更容易地支持多粒度封锁。
在存在行级锁和表级锁的情况下,事务 T 想要对表 A 加 X 锁,就需要先检测是否有其它事务对表 A 或者表 A 中的任意一行加了锁,那么就需要对表 A 的每一行都检测一次,这是非常耗时的。
意向锁在原来的 X/S 锁之上引入了 IX/IS,IX/IS 都是表锁,用来表示一个事务想要在表中的某个数据行上加 X 锁或 S 锁。有以下两个规定:
  • 一个事务在获得某个数据行对象的 S 锁之前,必须先获得表的 IS 锁或者更强的锁;
  • 一个事务在获得某个数据行对象的 X 锁之前,必须先获得表的 IX 锁。
通过引入意向锁,事务 T 想要对表 A 加 X 锁,只需要先检测是否有其它事务对表 A 加了 X/IX/S/IS 锁,如果加了就表示有其它事务正在使用这个表或者表中某一行的锁,因此事务 T 加 X 锁失败。
各种锁的兼容关系如下:
锁类型
X
IX
S
IS
X
IX
S
IS
解释如下:
  • 任意 IS/IX 锁之间都是兼容的,因为它们只是表示想要对表加锁,而不是真正加锁;
  • S 锁只与 S 锁和 IS 锁兼容,也就是说事务 T 想要对数据行加 S 锁,其它事务可以已经获得对表或者表中的行的 S 锁。

2.3. 锁协议

三级锁协议
一级锁协议
事务 T 要修改数据 A 时必须加 X 锁,直到 T 结束才释放锁。
可以解决丢失修改问题,因为不能同时有两个事务对同一个数据进行修改,那么事务的修改就不会被覆盖。
T1
T2
lock-x(A)
read A=20
lock-x(A)
wait
write A=19
.
commit
.
unlock-x(A)
.
obtain
read A=19
write A=21
commit
unlock-x(A)
二级锁协议
在一级的基础上,要求读取数据 A 时必须加 S 锁,读取完马上释放 S 锁。
可以解决读脏数据问题,因为如果一个事务在对数据 A 进行修改,根据 1 级封锁协议,会加 X 锁,那么就不能再加 S 锁了,也就是不会读入数据。
T1
T2
lock-x(A)
read A=20
write A=19
lock-s(A)
wait
rollback
.
A=20
.
unlock-x(A)
.
obtain
read A=20
commit
unlock-s(A)
三级锁协议
在二级的基础上,要求读取数据 A 时必须加 S 锁,直到事务结束了才能释放 S 锁。
可以解决不可重复读的问题,因为读 A 时,其它事务不能对 A 加 X 锁,从而避免了在读的期间数据发生改变。
T1
T2
lock-s(A)
read A=20
lock-x(A)
wait
read A=20
.
commit
.
unlock-s(A)
.
obtain
read A=20
write A=19
commit
unlock-X(A)
两段锁协议
加锁和解锁分为两个阶段进行。
可串行化调度是指:通过并发控制,使得并发执行的事务结果与某个串行执行的事务结果相同。
事务遵循两段锁协议是保证可串行化调度的充分条件。例如以下操作满足两段锁协议,它是可串行化调度。
lock-x(A)...lock-s(B)...lock-s(C)...unlock(A)...unlock(C)...unlock(B)
但不是必要条件,例如以下操作不满足两段锁协议,但是它还是可串行化调度。
lock-x(A)...unlock(A)...lock-s(B)...unlock(B)...lock-s(C)...unlock(C)

2.4. 死锁

死锁是指两个或者多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象
当多个事务试图以不同的顺序锁定资源时,就可能会产生死锁。多个事务同时锁定一个资源时,也会产生死锁。

3. 多版本并发控制

多版本并发控制(Multi-Version Concurrency Control, MVCC)是实现隔离级别的一种具体方式。
Mysql、Oracle、PostgreSQL 等数据库都实现了 MVCC,但各自的实现机制不尽相同。
MVCC 用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,无需使用 MVCC;可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。
MVCC 可以视为行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。
MVCC 的实现,是通过保存数据在某个时间的快照来实现的。

3.1. 版本号

  • 系统版本号 - 是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。
  • 事务版本号 - 事务开始时的系统版本号。
MVCC 在每行记录后面都保存着两个隐藏的列,用来存储两个版本号:
  • 创建版本号 - 指示创建一个数据行的快照时的系统版本号;
  • 删除版本号 - 如果该快照的删除版本号大于当前事务版本号表示该快照有效,否则表示该快照已经被删除了。

3.2. Undo 日志

MVCC 使用到的快照存储在 Undo 日志中,该日志通过回滚指针把一个数据行(Record)的所有快照连接起来。

3.3. 实现过程

以下实现过程针对可重复读隔离级别。
SELECT
当开始新一个事务时,该事务的版本号肯定会大于当前所有数据行快照的创建版本号,理解这一点很关键。
多个事务必须读取到同一个数据行的快照,并且这个快照是距离现在最近的一个有效快照。但是也有例外,如果有一个事务正在修改该数据行,那么它可以读取事务本身所做的修改,而不用和其它事务的读取结果一致。
把没有对一个数据行做修改的事务称为 T,T 所要读取的数据行快照的创建版本号必须小于 T 的版本号,因为如果大于或者等于 T 的版本号,那么表示该数据行快照是其它事务的最新修改,因此不能去读取它。
除了上面的要求,T 所要读取的数据行快照的删除版本号必须大于 T 的版本号,因为如果小于等于 T 的版本号,那么表示该数据行快照是已经被删除的,不应该去读取它。
INSERT
将当前系统版本号作为数据行快照的创建版本号。
Could not load image
DELETE
将当前系统版本号作为数据行快照的删除版本号。
Could not load image
UPDATE
将当前系统版本号作为更新后的数据行快照的创建版本号,同时将当前系统版本号作为更新前的数据行快照的删除版本号。可以理解为先执行 DELETE 后执行 INSERT。

3.4. 快照读与当前读

快照读
使用 MVCC 读取的是快照中的数据,这样可以减少加锁所带来的开销。
select * from table ...;
当前读
读取的是最新的数据,需要加锁。以下第一个语句需要加 S 锁,其它都需要加 X 锁。
select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update;
delete;

4. SQL 优化

4.1. 使用执行计划进行分析

执行计划 Explain 用来分析 SELECT 查询语句,开发人员可以通过分析 Explain 结果来优化查询语句。
比较重要的字段有:
  • select_type : 查询类型,有简单查询、联合查询、子查询等
  • key : 使用的索引
  • rows : 扫描的行数

4.2. 优化数据访问

减少请求的数据量
  • 只返回必要的列 - 最好不要使用 SELECT * 语句。
  • 只返回必要的行 - 使用 WHERE 语句进行查询过滤,有时候也需要使用 LIMIT 语句来限制返回的数据。
  • 缓存重复查询的数据 - 使用缓存可以避免在数据库中进行查询,特别要查询的数据经常被重复查询,缓存可以带来的查询性能提升将会是非常明显的。
减少服务器端扫描的行数
最有效的方式是使用索引来覆盖查询。

4.3. 重构查询方式

切分大查询
一个大查询如果一次性执行的话,可能一次锁住很多数据、占满整个事务日志、耗尽系统资源、阻塞很多小的但重要的查询。
DELEFT FROM messages WHERE create < DATE_SUB(NOW(), INTERVAL 3 MONTH);
rows_affected = 0
do {
rows_affected = do_query(
"DELETE FROM messages WHERE create < DATE_SUB(NOW(), INTERVAL 3 MONTH) LIMIT 10000")
} while rows_affected > 0
分解大连接查询
将一个大连接查询(JOIN)分解成对每一个表进行一次单表查询,然后将结果在应用程序中进行关联,这样做的好处有:
  • 缓存更高效。对于连接查询,如果其中一个表发生变化,那么整个查询缓存就无法使用。而分解后的多个查询,即使其中一个表发生变化,对其它表的查询缓存依然可以使用。
  • 分解成多个单表查询,这些单表查询的缓存结果更可能被其它查询使用到,从而减少冗余记录的查询
  • 减少锁竞争
  • 在应用层进行连接,可以更容易对数据库进行拆分,从而更容易做到高性能和可扩展。
  • 查询本身效率也可能会有所提升。例如下面的例子中,使用 IN() 代替连接查询,可以让 MySQL 按照 ID 顺序进行查询,这可能比随机的连接要更高效。
SELECT * FROM tag
JOIN tag_post ON tag_post.tag_id=tag.id
JOIN post ON tag_post.post_id=post.id
WHERE tag.tag='mysql';
SELECT * FROM tag WHERE tag='mysql';
SELECT * FROM tag_post WHERE tag_id=1234;
SELECT * FROM post WHERE post.id IN (123,456,567,9098,8904);

5. 索引

索引能够轻易将查询性能提升几个数量级。
  • 数据量小的表,使用全表扫描比建立索引更高效。
  • 数据量大的表,使用索引更高效。
  • 数据量特大的表,建立和维护索引的代价将会随之增长,可以使用分区技术。

5.1. 索引的优点和缺点

优点:
  • 索引大大减少了服务器需要扫描的数据量。
  • 索引可以帮助服务器避免排序和临时表。
  • 索引可以将随机 I/O 变为顺序 I/O。
缺点:
  • 创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增加。
  • 索引需要占物理空间,除了数据表占数据空间之外,每一个索引还要占一定的物理空间,如果要建立组合索引那么需要的空间就会更大。
  • 当对表中的数据进行增加、删除和修改的时候,索引也要动态的维护,这样就降低了数据的维护速度。

5.2. 索引类型

主流的关系型数据库一般都支持以下索引类型:
  • 普通索引:最基本的索引,没有任何限制。
  • 唯一索引:索引列的值必须唯一,但允许有空值。如果是组合索引,则列值的组合必须唯一。
  • 主键索引:一种特殊的唯一索引,一个表只能有一个主键,不允许有空值。一般是在建表的时候同时创建主键索引。
  • 组合索引:多个字段上创建的索引,只有在查询条件中使用了创建索引时的第一个字段,索引才会被使用。使用组合索引时遵循最左前缀集合。

5.3. 索引数据结构

主流数据库的索引一般使用的数据结构为:B-Tree 或 B+Tree。
B-Tree
B-Tree 不同于 Binary Tree(二叉树,最多有两个子树),它是平衡搜索树。
一棵 M 阶的 B-Tree 满足以下条件:
  • 每个结点至多有 M 个孩子;
  • 除根结点和叶结点外,其它每个结点至少有 M/2 个孩子;
  • 根结点至少有两个孩子(除非该树仅包含一个结点);
  • 所有叶结点在同一层,叶结点不包含任何关键字信息;
  • 有 K 个关键字的非叶结点恰好包含 K+1 个孩子;
对于任意结点,其内部的关键字 Key 是升序排列的。每个节点中都包含了 data。
对于每个结点,主要包含一个关键字数组 Key[],一个指针数组(指向儿子)Son[]。
在 B-Tree 内,查找的流程是:
  1. 1.
    使用顺序查找(数组长度较短时)或折半查找方法查找 Key[]数组,若找到关键字 K,则返回该结点的地址及 K 在 Key[]中的位置;
  2. 2.
    否则,可确定 K 在某个 Key[i]和 Key[i+1]之间,则从 Son[i]所指的子结点继续查找,直到在某结点中查找成功;
  3. 3.
    或直至找到叶结点且叶结点中的查找仍不成功时,查找过程失败。
B+Tree
B+Tree 是 B-Tree 的变种:
  • 每个节点的指针上限为 2d 而不是 2d+1(d 为节点的出度)。
  • 非叶子节点不存储 data,只存储 key;叶子节点不存储指针。
由于并不是所有节点都具有相同的域,因此 B+Tree 中叶节点和内节点一般大小不同。这点与 B-Tree 不同,虽然 B-Tree 中不同节点存放的 key 和指针可能数量不一致,但是每个节点的域和上限是一致的,所以在实现中 B-Tree 往往对每个节点申请同等大小的空间。
带有顺序访问指针的 B+Tree
一般在数据库系统或文件系统中使用的 B+Tree 结构都在经典 B+Tree 的基础上进行了优化,增加了顺序访问指针。
在 B+Tree 的每个叶子节点增加一个指向相邻叶子节点的指针,就形成了带有顺序访问指针的 B+Tree。
这个优化的目的是为了提高区间访问的性能,例如上图中如果要查询 key 为从 18 到 49 的所有数据记录,当找到 18 后,只需顺着节点和指针顺序遍历就可以一次性访问到所有数据节点,极大提到了区间查询效率。
Hash
Hash 索引只有精确匹配索引所有列的查询才有效。
对于每一行数据,对所有的索引列计算一个 hashcode。哈希索引将所有的 hashcode 存储在索引中,同时在 Hash 表中保存指向每个数据行的指针。
哈希索引的优点:
  • 因为索引数据结构紧凑,所以查询速度非常快。
哈希索引的缺点:
  • 哈希索引数据不是按照索引值顺序存储的,所以无法用于排序。
  • 哈希索引不支持部分索引匹配查找。如,在数据列 (A,B) 上建立哈希索引,如果查询只有数据列 A,无法使用该索引。
  • 哈希索引只支持等值比较查询,不支持任何范围查询,如 WHERE price > 100。
  • 哈希索引有可能出现哈希冲突,出现哈希冲突时,必须遍历链表中所有的行指针,逐行比较,直到找到符合条件的行。

5.4. 索引原则

  • 独立的列
如果查询中的列不是独立的列,则数据库不会使用索引。
“独立的列” 是指索引列不能是表达式的一部分,也不能是函数的参数。
❌ 错误示例:
SELECT actor_id FROM sakila.actor WHERE actor_id + 1 = 5;
SELECT ... WHERE TO_DAYS(CURRENT_DAT) - TO_DAYS(date_col) <= 10;
  • 前缀索引和索引选择性
有时候需要索引很长的字符列,这会让索引变得大且慢。
解决方法是:可以索引开始的部分字符,这样可以大大节约索引空间,从而提高索引效率。但这样也会降低索引的选择性。
索引的选择性是指:不重复的索引值和数据表记录总数的比值。最大值为 1,此时每个记录都有唯一的索引与其对应。选择性越高,查询效率也越高。
对于 BLOB/TEXT/VARCHAR 这种文本类型的列,必须使用前缀索引,因为数据库往往不允许索引这些列的完整长度。
要选择足够长的前缀以保证较高的选择性,同时又不能太长(节约空间)。
  • 多列索引
不要为每个列创建独立的索引。
  • 选择合适的索引列顺序
经验法则:将选择性高的列或基数大的列优先排在多列索引最前列。
但有时,也需要考虑 WHERE 子句中的排序、分组和范围条件等因素,这些因素也会对查询性能造成较大影响。
  • 聚簇索引
聚簇索引不是一种单独的索引类型,而是一种数据存储方式。
聚簇表示数据行和相邻的键值紧凑地存储在一起。因为无法同时把数据行存放在两个不同的地方,所以一个表只能有一个聚簇索引。
  • 覆盖索引
索引包含所有需要查询的字段的值。
具有以下优点:
  • 因为索引条目通常远小于数据行的大小,所以若只读取索引,能大大减少数据访问量。
  • 一些存储引擎(例如 MyISAM)在内存中只缓存索引,而数据依赖于操作系统来缓存。因此,只访问索引可以不使用系统调用(通常比较费时)。
  • 对于 InnoDB 引擎,若辅助索引能够覆盖查询,则无需访问主索引。
  • 使用索引扫描来做排序
索引最好既满足排序,又用于查找行。这样,就可以使用索引来对结果排序。
  • = 和 in 可以乱序
比如 a = 1 and b = 2 and c = 3 建立(a,b,c)索引可以任意顺序,mysql 的查询优化器会帮你优化成索引可以识别的形式。
  • 尽量的扩展索引,不要新建索引
比如表中已经有 a 的索引,现在要加(a,b)的索引,那么只需要修改原来的索引即可。

6. 分库分表

分库分表的基本思想就要把一个数据库切分成多个部分放到不同的数据库(server)上,从而缓解单一数据库的性能问题。
当然,现实中更多是这两种情况混杂在一起,这时候需要根据实际情况做出选择,也可能会综合使用垂直与水平切分,从而将原有数据库切分成类似矩阵一样可以无限扩充的数据库(server)阵列。

6.1. 水平拆分

对于海量数据的数据库,如果表并不多,但每张表的数据非常多,这时候适合水平切分,即把表的数据按某种规则(比如按 ID 散列)切分到多个数据库(server)上。
水平切分又称为 Sharding,它是将同一个表中的记录拆分到多个结构相同的表中。

6.2. 垂直拆分

垂直切分是将一张表按列切分成多个表,通常是按照列的关系密集程度进行切分,也可以利用垂直切分将经常被使用的列和不经常被使用的列切分到不同的表中。
如果是因为表多而数据多,这时候适合使用垂直切分,即把关系紧密(比如同一模块)的表切分出来放在一个 server 上。

6.3. Sharding 策略

  • 哈希取模:hash(key) % NUM_DB
  • 范围:可以是 ID 范围也可以是时间范围
  • 映射表:使用单独的一个数据库来存储映射关系

6.4. 分库分表的问题及解决方案

事务问题
方案一:使用分布式事务
  • 优点:交由数据库管理,简单有效
  • 缺点:性能代价高,特别是 shard 越来越多时
方案二:由应用程序和数据库共同控制
  • 原理:将一个跨多个数据库的分布式事务分拆成多个仅处于单个数据库上面的小事务,并通过应用程序来总控各个小事务。
  • 优点:性能上有优势
  • 缺点:需要应用程序在事务控制上做灵活设计。如果使用了 spring 的事务管理,改动起来会面临一定的困难。
跨节点 Join 的问题
只要是进行切分,跨节点 Join 的问题是不可避免的。但是良好的设计和切分却可以减少此类情况的发生。解决这一问题的普遍做法是分两次查询实现。在第一次查询的结果集中找出关联数据的 id,根据这些 id 发起第二次请求得到关联数据。
跨节点的 count,order by,group by 以及聚合函数问题
这些是一类问题,因为它们都需要基于全部数据集合进行计算。多数的代理都不会自动处理合并工作。
解决方案:与解决跨节点 join 问题的类似,分别在各个节点上得到结果后在应用程序端进行合并。和 join 不同的是每个节点的查询可以并行执行,因此很多时候它的速度要比单一大表快很多。但如果结果集很大,对应用程序内存的消耗是一个问题。
ID 唯一性
一旦数据库被切分到多个物理节点上,我们将不能再依赖数据库自身的主键生成机制。一方面,某个分区数据库自生成的 ID 无法保证在全局上是唯一的;另一方面,应用程序在插入数据之前需要先获得 ID,以便进行 SQL 路由。
一些常见的主键生成策略:
  • 使用全局唯一 ID:GUID。
  • 为每个分片指定一个 ID 范围。
  • 分布式 ID 生成器 (如 Twitter 的 Snowflake 算法)。
数据迁移,容量规划,扩容等问题
来自淘宝综合业务平台团队,它利用对 2 的倍数取余具有向前兼容的特性(如对 4 取余得 1 的数对 2 取余也是 1)来分配数据,避免了行级别的数据迁移,但是依然需要进行表级别的迁移,同时对扩容规模和分表数量都有限制。总得来说,这些方案都不是十分的理想,多多少少都存在一些缺点,这也从一个侧面反映出了 Sharding 扩容的难度。
分库数量
分库数量首先和单库能处理的记录数有关,一般来说,Mysql 单库超过 5000 万条记录,Oracle 单库超过 1 亿条记录,DB 压力就很大(当然处理能力和字段数量/访问模式/记录长度有进一步关系)。
跨分片的排序分页
  • 如果是在前台应用提供分页,则限定用户只能看前面 n 页,这个限制在业务上也是合理的,一般看后面的分页意义不大(如果一定要看,可以要求用户缩小范围重新查询)。
  • 如果是后台批处理任务要求分批获取数据,则可以加大 page size,比如每次获取 5000 条记录,有效减少分页数(当然离线访问一般走备库,避免冲击主库)。
  • 分库设计时,一般还有配套大数据平台汇总所有分库的记录,有些分页查询可以考虑走大数据平台。

6.5. 常用的分库分表中间件

简单易用的组件:
强悍重量级的中间件:

7. 关系数据库设计理论

7.1. 函数依赖

记 A->B 表示 A 函数决定 B,也可以说 B 函数依赖于 A。
如果 {A1,A2,... ,An} 是关系的一个或多个属性的集合,该集合函数决定了关系的其它所有属性并且是最小的,那么该集合就称为键码。
对于 A->B,如果能找到 A 的真子集 A',使得 A'-> B,那么 A->B 就是部分函数依赖,否则就是完全函数依赖;
对于 A->B,B->C,则 A->C 是一个传递依赖。

7.2. 异常

以下的学生课程关系的函数依赖为 Sno, Cname -> Sname, Sdept, Mname, Grade,键码为 {Sno, Cname}。也就是说,确定学生和课程之后,就能确定其它信息。
Sno
Sname
Sdept
Mname
Cname
Grade
1
学生-1
学院-1
院长-1
课程-1
90
2
学生-2
学院-2
院长-2
课程-2
80
2
学生-2
学院-2
院长-2
课程-1
100
3
学生-3
学院-2
院长-2
课程-2
95
不符合范式的关系,会产生很多异常,主要有以下四种异常:
  • 冗余数据:例如 学生-2 出现了两次。
  • 修改异常:修改了一个记录中的信息,但是另一个记录中相同的信息却没有被修改。
  • 删除异常:删除一个信息,那么也会丢失其它信息。例如如果删除了 课程-1,需要删除第一行和第三行,那么 学生-1 的信息就会丢失。
  • 插入异常,例如想要插入一个学生的信息,如果这个学生还没选课,那么就无法插入。

7.3. 范式

范式理论是为了解决以上提到四种异常。
高级别范式的依赖于低级别的范式,1NF 是最低级别的范式。
Could not load image
第一范式 (1NF)
属性不可分。
第二范式 (2NF)
  • 每个非主属性完全函数依赖于键码。
  • 可以通过分解来满足。
分解前
Sno
Sname
Sdept
Mname
Cname
Grade
1
学生-1
学院-1
院长-1
课程-1
90
2
学生-2
学院-2
院长-2
课程-2
80
2
学生-2
学院-2
院长-2
课程-1
100
3
学生-3
学院-2
院长-2
课程-2
95
以上学生课程关系中,{Sno, Cname} 为键码,有如下函数依赖:
  • Sno -> Sname, Sdept
  • Sdept -> Mname
  • Sno, Cname-> Grade
Grade 完全函数依赖于键码,它没有任何冗余数据,每个学生的每门课都有特定的成绩。
Sname, Sdept 和 Mname 都部分依赖于键码,当一个学生选修了多门课时,这些数据就会出现多次,造成大量冗余数据。
分解后
关系-1
Sno
Sname
Sdept
Mname
1
学生-1
学院-1
院长-1
2
学生-2
学院-2
院长-2
3
学生-3
学院-2
院长-2
有以下函数依赖:
  • Sno -> Sname, Sdept, Mname
  • Sdept -> Mname
关系-2
Sno
Cname
Grade
1
课程-1
90
2
课程-2
80
2
课程-1
100
3
课程-2
95
有以下函数依赖:
  • Sno, Cname -> Grade
第三范式 (3NF)
  • 非主属性不传递依赖于键码。
上面的 关系-1 中存在以下传递依赖:Sno -> Sdept -> Mname,可以进行以下分解:
关系-11
Sno
Sname
Sdept
1
学生-1
学院-1
2
学生-2
学院-2
3
学生-3
学院-2
关系-12
Sdept
Mname
学院-1
院长-1
学院-2
院长-2

8. 参考资料