Skip to content

SQL

索引和约束

索引是对数据库表中一或多个列的值进行排序的结构, 是帮助数据库高效查询数据的数据结构

索引的优缺点

索引的优点:

  • 索引大大减少了服务器需要扫描的数据量, 从而加快检索速度
  • 支持行级锁的数据库, 如 InnoDB 会在访问行的时候加锁. 使用索引可以减少访问的行数, 从而减少锁的竞争, 提高并发
  • 索引可以帮助服务器避免排序和临时表
  • 索引可以将随机 I/O 变为顺序 I/O
  • 唯一索引可以确保每一行数据的唯一性, 通过使用索引, 可以在查询的过程中使用优化隐藏器, 提高系统的性能

索引的缺点:

  • 创建和维护索引要耗费时间, 这会随着数据量的增加而增加
  • 索引需要占用额外的物理空间, 除了数据表占数据空间之外, 每一个索引还要占一定的物理空间, 如果要建立组合索引那么需要的空间就会更大
  • 写操作 (INSERT/UPDATE/DELETE) 时很可能需要更新索引, 导致数据库的写操作性能降低

何时使用索引

索引能够轻易将查询性能提升几个数量级

什么情况适用索引:

  • 表经常进行 SELECT 操作;
  • 表的数据量比较大;
  • 列名经常出现在 WHERE 或连接 (JOIN) 条件中

什么情况不适用索引:

  • 频繁写操作 ( INSERT/UPDATE/DELETE ) - 需要更新索引空间;
  • 非常小的表 - 对于非常小的表, 大部分情况下简单的全表扫描更高效
  • 列名不经常出现在 WHERE 或连接 (JOIN) 条件中 - 索引就会经常不命中, 没有意义, 还增加空间开销
  • 对于特大型表, 建立和使用索引的代价将随之增长. 可以考虑使用分区技术或 Nosql

索引的类型

主流的关系型数据库一般都支持以下索引类型:

从逻辑类型上划分 (即一般创建表时设置的索引类型):

  • 唯一索引 (UNIQUE): 索引列的值必须唯一, 但允许有空值. 如果是组合索引, 则列值的组合必须唯一
  • 主键索引 (PRIMARY): 一种特殊的唯一索引, 一个表只能有一个主键, 不允许有空值. 一般是在建表的时候同时创建主键索引
  • 普通索引 (INDEX): 最基本的索引, 没有任何限制
  • 组合索引: 多个字段上创建的索引, 只有在查询条件中使用了创建索引时的第一个字段, 索引才会被使用. 使用组合索引时遵循最左前缀集合

从物理存储上划分:

  • 聚集索引(Clustered): 表中各行的物理顺序与键值的逻辑 (索引) 顺序相同, 每个表只能有一个
  • 非聚集索引(Non-clustered): 非聚集索引指定表的逻辑顺序, 也可以视为二级索引. 数据存储在一个位置, 索引存储在另一个位置, 索引中包含指向数据存储位置的指针. 可以有多个, 小于 249 个

索引的数据结构

主流数据库的索引一般使用的数据结构为: B 树, B+ 树

B 树

一棵 M 阶的 B-Tree 满足以下条件:

  • 每个结点至多有 M 个孩子;
  • 除根结点和叶结点外, 其它每个结点至少有 M/2 个孩子;
  • 根结点至少有两个孩子 (除非该树仅包含一个结点);
  • 所有叶结点在同一层, 叶结点不包含任何关键字信息;
  • 有 K 个关键字的非叶结点恰好包含 K+1 个孩子;

对于任意结点, 其内部的关键字 Key 是升序排列的. 每个节点中都包含了 data

对于每个结点, 主要包含一个关键字数组 Key[], 一个指针数组 (指向儿子) Son[]

在 B-Tree 内, 查找的流程是:

  1. 使用顺序查找 (数组长度较短时) 或折半查找方法查找 Key[] 数组, 若找到关键字 K, 则返回该结点的地址及 K 在 Key[] 中的位置;
  2. 否则, 可确定 K 在某个 Key[i] 和 Key[i+1] 之间, 则从 Son[i] 所指的子结点继续查找, 直到在某结点中查找成功;
  3. 或直至找到叶结点且叶结点中的查找仍不成功时, 查找过程失败

B+ 树

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 后, 只需顺着节点和指针顺序遍历就可以一次性访问到所有数据节点, 极大提到了区间查询效率

B 树 vs. B+ 树

  • B+ 树更适合外部存储(一般指磁盘存储), 由于内节点(非叶子节点)不存储 data, 所以一个节点可以存储更多的内节点, 每个节点能索引的范围更大更精确. 也就是说使用 B+ 树单次磁盘 IO 的信息量相比较 B 树更大, IO 效率更高
  • Mysql 是关系型数据库, 经常会按照区间来访问某个索引列, B+ 树的叶子节点间按顺序建立了链指针, 加强了区间访问性, 所以 B+ 树对索引列上的区间范围查询很友好. 而 B 树每个节点的 key 和 data 在一起, 无法进行区间查找

Hash

Hash 索引只有精确匹配索引所有列的查询才有效

对于每一行数据, 对所有的索引列计算一个 hashcode. 哈希索引将所有的 hashcode 存储在索引中, 同时在 Hash 表中保存指向每个数据行的指针

哈希结构索引的优点:

  • 因为索引数据结构紧凑, 所以查询速度非常快

哈希结构索引的缺点:

  • 哈希索引数据不是按照索引值顺序存储的, 所以无法用于排序
  • 哈希索引不支持部分索引匹配查找. 如, 在数据列 (A,B) 上建立哈希索引, 如果查询只有数据列 A, 无法使用该索引
  • 哈希索引只支持等值比较查询, 不支持任何范围查询, 如 WHERE price > 100
  • 哈希索引有可能出现哈希冲突, 出现哈希冲突时, 必须遍历链表中所有的行指针, 逐行比较, 直到找到符合条件的行

索引策略

索引基本原则

  • 索引不是越多越好, 不要为所有列都创建索引
  • 要尽量避免冗余和重复索引;
  • 要考虑删除未使用的索引;
  • 尽量的扩展索引, 不要新建索引;
  • 频繁作为 WHERE 过滤条件的列应该考虑添加索引

独立的列

如果查询中的列不是独立的列, 则数据库不会使用索引

"独立的列" 是指索引列不能是表达式的一部分, 也不能是函数的参数

❌ 错误示例:

sql
SELECT actor_id FROM actor WHERE actor_id + 1 = 5;
SELECT ... WHERE TO_DAYS(current_date) - TO_DAYS(date_col) <= 10;

前缀索引和索引选择性

有时候需要索引很长的字符列, 这会让索引变得大且慢

解决方法是: 可以索引开始的部分字符, 这样可以大大节约索引空间, 从而提高索引效率. 但这样也会降低索引的选择性

索引的选择性是指: 不重复的索引值和数据表记录总数的比值. 最大值为 1, 此时每个记录都有唯一的索引与其对应. 选择性越高, 查询效率也越高

对于 BLOB/TEXT/VARCHAR 这种文本类型的列, 必须使用前缀索引, 因为数据库往往不允许索引这些列的完整长度

要选择足够长的前缀以保证较高的选择性, 同时又不能太长 (节约空间)

❌ 低效示例:

sql
SELECT COUNT(*) AS cnt, city FROM sakila.city_demo
GROUP BY city ORDER BY cnt DESC LIMIT 10;

✔ 高效示例:

sql
SELECT COUNT(*) AS cnt, LEFT(city, 3) AS pref FROM sakila.city_demo
GROUP BY city ORDER BY cnt DESC LIMIT 10;

多列索引

不要为每个列都创建独立索引

将选择性高的列或基数大的列优先排在多列索引最前列. 但有时, 也需要考虑 WHERE 子句中的排序, 分组和范围条件等因素, 这些因素也会对查询性能造成较大影响

举例来说, 有一张 user 表, 其中含 name, sex, age 三个列, 如果将这三者组合为多列索引, 应该用什么样的顺序呢? 从选择性高的角度来看: name > age > sex

聚簇索引

聚簇索引不是一种单独的索引类型, 而是一种数据存储方式. 具体细节依赖于实现方式. 如 InnoDB 的聚簇索引实际是在同一个结构中保存了 B 树的索引和数据行

聚簇表示数据行和相邻的键值紧凑地存储在一起, 因为数据紧凑, 所以访问快. 因为无法同时把数据行存放在两个不同的地方, 所以一个表只能有一个聚簇索引

若没有定义主键, InnoDB 会隐式定义一个主键来作为聚簇索引

覆盖索引

索引包含所有需要查询的字段的值

具有以下优点:

  • 因为索引条目通常远小于数据行的大小, 所以若只读取索引, 能大大减少数据访问量
  • 一些存储引擎 (例如 MyISAM)在内存中只缓存索引, 而数据依赖于操作系统来缓存. 因此, 只访问索引可以不使用系统调用 (通常比较费时)
  • 对于 InnoDB 引擎, 若辅助索引能够覆盖查询, 则无需访问主索引

使用索引扫描来做排序

Mysql 有两种方式可以生成排序结果: 通过排序操作; 或者按索引顺序扫描

索引最好既满足排序, 又用于查找行. 这样, 就可以使用索引来对结果排序

最左前缀匹配原则

MySQL 会一直向右匹配直到遇到范围查询 (>,<,BETWEEN,LIKE) 就停止匹配

  • 索引可以简单如一个列(a), 也可以复杂如多个列(a, b, c, d), 即联合索引
  • 如果是联合索引, 那么 key 也由多个列组成, 同时, 索引只能用于查找 key 是否存在 (相等), 遇到范围查询(>, <, between, like 左匹配)等就不能进一步匹配了, 后续退化为线性查找
  • 因此, 列的排列顺序决定了可命中索引的列数

例子:

  • 如有索引(a, b, c, d), 查询条件 a = 1 and b = 2 and c > 3 and d = 4, 则会在每个节点依次命中 a, b, c, 无法命中 d. (很简单: 索引命中只能是相等的情况, 不能是范围匹配)

= 和 in 可以乱序

不需要考虑=, in 等的顺序, Mysql 会自动优化这些条件的顺序, 以匹配尽可能多的索引列

例子: 如有索引(a, b, c, d), 查询条件 c > 3 and b = 2 and a = 1 and d < 4 与 a = 1 and c > 3 and b = 2 and d < 4 等顺序都是可以的, MySQL 会自动优化为 a = 1 and b = 2 and c > 3 and d < 4, 依次命中 a, b, c

约束

数据库约束 (CONSTRAINT) 有哪些:

  • NOT NULL - 用于控制字段的内容一定不能为空 (NULL)
  • UNIQUE - 字段内容不能重复, 一个表允许有多个 Unique 约束
  • PRIMARY KEY - 数据表中对储存数据对象予以唯一和完整标识的数据列或属性的组合, 它在一个表中只允许有一个. 主键的取值不能为空值 (Null)
  • FOREIGN KEY - 在一个表中存在的另一个表的主键称此表的外键. 用于预防破坏表之间连接的动作, 也能防止非法数据插入外键列, 因为它必须是它指向的那个表中的值之一
  • CHECK - 用于控制字段的值范围

并发控制

乐观锁和悲观锁

  • 数据库的乐观锁和悲观锁是什么?
  • 数据库的乐观锁和悲观锁如何实现?

确保在多个事务同时存取数据库中同一数据时不破坏事务的隔离性和统一性以及数据库的统一性, **乐观锁和悲观锁是并发控制主要采用的技术手段. **

  • 悲观锁 - 假定会发生并发冲突, 屏蔽一切可能违反数据完整性的操作
    • 在查询完数据的时候就把事务锁起来, 直到提交事务 (COMMIT)
    • 实现方式: 使用数据库中的锁机制
  • 乐观锁 - 假设不会发生并发冲突, 只在提交操作时检查是否违反数据完整性
    • 在修改数据的时候把事务锁起来, 通过 version 的方式来进行锁定
    • 实现方式: 使用 version 版本或者时间戳

行级锁和表级锁

  • 什么是行级锁和表级锁?
  • 什么时候用行级锁? 什么时候用表级锁?

从数据库的锁粒度来看, MySQL 中提供了两种封锁粒度: 行级锁和表级锁

  • 表级锁 (table lock) - 锁定整张表. 用户对表进行写操作前, 需要先获得写锁, 这会阻塞其他用户对该表的所有读写操作. 只有没有写锁时, 其他用户才能获得读锁, 读锁之间不会相互阻塞
  • 行级锁 (row lock) - 仅对指定的行记录进行加锁, 这样其它进程还是可以对同一个表中的其它记录进行操作

二者需要权衡:

  • 锁定的数据量越少, 锁竞争的发生频率就越小, 系统的并发程度就越高
  • 锁粒度越小, 系统开销就越大

InnoDB 中, 行锁是通过给索引上的索引项加锁来实现的. 如果没有索引, InnoDB 将会通过隐藏的聚簇索引来对记录加锁

读写锁

  • 什么是读写锁?
  • 独享锁 (Exclusive), 简写为 X 锁, 又称写锁. 使用方式: SELECT ... FOR UPDATE;
  • 共享锁 (Shared), 简写为 S 锁, 又称读锁. 使用方式: SELECT ... LOCK IN SHARE MODE;

写锁和读锁的关系, 简言之: 独享锁存在, 其他事务就不能做任何操作

InnoDB 下的行锁, 间隙锁, next-key 锁统统属于独享锁

意向锁

  • 什么是意向锁?
  • 意向锁有什么用?

意向锁的作用是: 当存在表级锁和行级锁的情况下, 必须先申请意向锁 (表级锁, 但不是真的加锁), 再获取行级锁. 使用意向锁 (Intention Locks)可以更容易地支持多粒度封锁

意向锁是 InnoDB 自动加的, 不需要用户干预

MVCC

什么是 MVCC?

MVCC 有什么用? 解决了什么问题?

MVCC 的原理是什么?

多版本并发控制 (Multi-Version Concurrency Control, MVCC) 是 InnoDB 存储引擎实现隔离级别的一种具体方式, 用于实现提交读和可重复读这两种隔离级别. 而未提交读隔离级别总是读取最新的数据行, 要求很低, 无需使用 MVCC. 可串行化隔离级别需要对所有读取的行都加锁, 单纯使用 MVCC 无法实现

MVCC 的思想是:

  • 保存数据在某个时间点的快照. 写操作 (DELETE, INSERT, UPDATE)更新最新的版本快照, 而读操作去读旧版本快照, 没有互斥关系, 这一点和 CopyOnWrite 类似
  • 脏读和不可重复读最根本的原因是事务读取到其它事务未提交的修改. 在事务进行读取操作时, 为了解决脏读和不可重复读问题, MVCC 规定只能读取已经提交的快照. 当然一个事务可以读取自身未提交的快照, 这不算是脏读

Next-key 锁

Next-Key 锁是 MySQL 的 InnoDB 存储引擎的一种锁实现

MVCC 不能解决幻读问题, Next-Key 锁就是为了解决幻读问题. 在可重复读 (REPEATABLE READ)隔离级别下, 使用 MVCC + Next-Key 锁 可以解决幻读问题

另外, 根据针对 SQL 语句检索条件的不同, 加锁又有以下三种情形需要我们掌握

  • Record Lock - 行锁对索引项加锁, 若没有索引则使用表锁
  • Gap Lock - 对索引项之间的间隙加锁. 锁定索引之间的间隙, 但是不包含索引本身. 例如当一个事务执行以下语句, 其它事务就不能在 t.c 中插入 15. SELECT c FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE;
  • Next-key lock -它是 Record LockGap Lock 的结合, 不仅锁定一个记录上的索引, 也锁定索引之间的间隙. 它锁定一个前开后闭区间

索引分为主键索引和非主键索引两种, 如果一条 SQL 语句操作了主键索引, MySQL 就会锁定这条主键索引; 如果一条语句操作了非主键索引, MySQL 会先锁定该非主键索引, 再锁定相关的主键索引. 在 UPDATE, DELETE 操作时, MySQL 不仅锁定 WHERE 条件扫描过的所有索引记录, 而且会锁定相邻的键值, 即所谓的 next-key lock

当两个事务同时执行, 一个锁住了主键索引, 在等待其他相关索引. 另一个锁定了非主键索引, 在等待主键索引. 这样就会发生死锁. 发生死锁后, InnoDB 一般都可以检测到, 并使一个事务释放锁回退, 另一个获取锁完成事务

事务

事务简单来说: 一个 Session 中所进行所有的操作, 要么同时成功, 要么同时失败. 具体来说, 事务指的是满足 ACID 特性的一组操作, 可以通过 Commit 提交一个事务, 也可以使用 Rollback 进行回滚

ACID

ACID — 数据库事务正确执行的四个基本要素:

  • 原子性 (Atomicity)
  • 一致性 (Consistency)
  • 隔离性 (Isolation)
  • 持久性 (Durability)

一个支持事务 (Transaction)中的数据库系统, 必需要具有这四种特性, 否则在事务过程 (Transaction processing)当中无法保证数据的正确性, 交易过程极可能达不到交易

并发一致性问题

在并发环境下, 事务的隔离性很难保证, 因此会出现很多并发一致性问题

  • 丢失修改

T1 和 T2 两个事务都对一个数据进行修改, T1 先修改, T2 随后修改, T2 的修改覆盖了 T1 的修改

  • 脏读

T1 修改一个数据, T2 随后读取这个数据. 如果 T1 撤销了这次修改, 那么 T2 读取的数据是脏数据

  • 不可重复读

T2 读取一个数据, T1 对该数据做了修改. 如果 T2 再次读取这个数据, 此时读取的结果和第一次读取的结果不同

  • 幻读

T1 读取某个范围的数据, T2 在这个范围内插入新的数据, T1 再次读取这个范围的数据, 此时读取的结果和和第一次读取的结果不同

并发一致性解决方案:

产生并发不一致性问题主要原因是破坏了事务的隔离性, 解决方法是通过并发控制来保证隔离性

并发控制可以通过封锁来实现, 但是封锁操作需要用户自己控制, 相当复杂. 数据库管理系统提供了事务的隔离级别, 让用户以一种更轻松的方式处理并发一致性问题

事务隔离

数据库隔离级别:

  • 未提交读 (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的最终一致性事务都不是银弹, 只有在最适合的场景中才能发挥它们的最大长处. 可通过下表详细对比它们之间的区别, 以帮助开发者进行技术选型

事务方案对比

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

分库分表

什么是分库分表

什么是分库分表? 什么是垂直拆分? 什么是水平拆分? 什么是 Sharding?

分库分表是为了解决什么问题?

分库分表有什么优点?

分库分表有什么策略?

分库分表的基本思想就是: 把原本完整的数据切分成多个部分, 放到不同的数据库或表上

分库分表一定是为了支撑 高并发, 数据量大两个问题的

垂直切分

垂直切分, 是 把一个有很多字段的表给拆分成多个表, 或者是多个库上去. 一般来说, 会 将较少的, 访问频率较高的字段放到一个表里去, 然后 将较多的, 访问频率较低的字段放到另外一个表里去. 因为数据库是有缓存的, 访问频率高的行字段越少, 就可以在缓存里缓存更多的行, 性能就越好. 这个一般在表层面做的较多一些

一般来说, 满足下面的条件就可以考虑扩容了:

  • Mysql 单库超过 5000 万条记录, Oracle 单库超过 1 亿条记录, DB 压力就很大
  • 单库超过每秒 2000 个并发时, 而一个健康的单库最好保持在每秒 1000 个并发左右, 不要太大

在数据库的层面使用垂直切分将按数据库中表的密集程度部署到不同的库中, 例如将原来的电商数据库垂直切分成商品数据库, 用户数据库等

水平拆分

水平拆分 又称为 Sharding, 它是将同一个表中的记录拆分到多个结构相同的表中. 当 单表数据量太大 时, 会极大影响 SQL 执行的性能 . 分表是将原来一张表的数据分布到数据库集群的不同节点上, 从而缓解单点的压力

一般来说, 单表有 200 万条数据 的时候, 性能就会相对差一些了, 需要考虑分表了. 但是, 这也要视具体情况而定, 可能是 100 万条, 也可能是 500 万条, SQL 越复杂, 就最好让单表行数越少

分库分表的优点

#分库分表前分库分表后
并发支撑情况单机部署, 扛不住高并发从单机到多机, 能承受的并发增加了多倍
磁盘使用情况单机磁盘容量几乎撑满拆分为多个库, 数据库服务器磁盘使用率大大降低
SQL 执行性能单表数据量太大, SQL 越跑越慢单表数据量减少, SQL 执行效率明显提升

分库分表策略

  • 哈希取模: hash(key) % Nid % N
    • 优点: 可以平均分配每个库的数据量和请求压力 (负载均衡)
    • 缺点: 扩容麻烦, 需要数据迁移
  • 范围: 可以按照 ID 或时间划分范围
    • 优点: 扩容简单
    • 缺点: 这种策略容易产生热点问题
  • 映射表: 使用单独的一个数据库来存储映射关系
    • 缺点: 存储映射关系的数据库也可能成为性能瓶颈, 且一旦宕机, 分库分表的数据库就无法工作. 所以不建议使用这种策略
    • 优点: 扩容简单, 可以解决分布式 ID 问题

分库分表的问题

  • 分库分表的常见问题有哪些?

  • 你是如何解决分库分表的问题的?

下文一一讲解常见分库分表的问题及解决方案

分布式事务

方案一: 使用数据库事务

  • 优点: 交由数据库管理, 简单有效
  • 缺点: 性能代价高, 特别是 shard 越来越多时

方案二: 由应用程序和数据库共同控制

  • 原理: 将一个跨多个数据库的分布式事务分拆成多个仅处于单个数据库上面的小事务, 并通过应用程序来总控各个小事务
  • 优点: 性能上有优势
  • 缺点: 需要应用程序在事务控制上做灵活设计. 如果使用了 spring 的事务管理, 改动起来会面临一定的困难

跨节点 Join

只要是进行切分, 跨节点 Join 的问题是不可避免的. 但是良好的设计和切分却可以减少此类情况的发生. 解决这一问题的普遍做法是分两次查询实现. 在第一次查询的结果集中找出关联数据的 id, 根据这些 id 发起第二次请求得到关联数据

跨节点的 count,order by,group by 以及聚合函数

这些是一类问题, 因为它们都需要基于全部数据集合进行计算. 多数的代理都不会自动处理合并工作

解决方案: 与解决跨节点 join 问题的类似, 分别在各个节点上得到结果后在应用程序端进行合并. 和 join 不同的是每个节点的查询可以并行执行, 因此很多时候它的速度要比单一大表快很多. 但如果结果集很大, 对应用程序内存的消耗是一个问题

业务角度上的解决方案:

  • 如果是在前台应用提供分页, 则限定用户只能看前面 n 页, 这个限制在业务上也是合理的, 一般看后面的分页意义不大 (如果一定要看, 可以要求用户缩小范围重新查询)
  • 如果是后台批处理任务要求分批获取数据, 则可以加大 page size, 比如每次获取 5000 条记录, 有效减少分页数 (当然离线访问一般走备库, 避免冲击主库)
  • 分库设计时, 一般还有配套大数据平台汇总所有分库的记录, 有些分页查询可以考虑走大数据平台

分布式 ID

一旦数据库被切分到多个物理节点上, 我们将不能再依赖数据库自身的主键生成机制. 一方面, 某个分区数据库自生成的 ID 无法保证在全局上是唯一的; 另一方面, 应用程序在插入数据之前需要先获得 ID, 以便进行 SQL 路由

一些常见的主键生成策略:

  • 使用全局唯一 ID: GUID
  • 为每个分片指定一个 ID 范围
  • 分布式 ID 生成器 (如 Twitter 的 Snowflake 算法)

数据迁移, 容量规划, 扩容等问题

来自淘宝综合业务平台团队, 它利用对 2 的倍数取余具有向前兼容的特性 (如对 4 取余得 1 的数对 2 取余也是 1) 来分配数据, 避免了行级别的数据迁移, 但是依然需要进行表级别的迁移, 同时对扩容规模和分表数量都有限制. 总得来说, 这些方案都不是十分的理想, 多多少少都存在一些缺点, 这也从一个侧面反映出了 Sharding 扩容的难度

集群

这个专题需要根据熟悉哪个数据库而定, 但是主流, 成熟的数据库都会实现一些基本功能, 只是实现方式, 策略上有所差异. 由于本人较为熟悉 Mysql, 所以下面主要介绍 Mysql 系统架构问题

复制机制

Mysql 支持两种复制: 基于行的复制和基于语句的复制

这两种方式都是在主库上记录二进制日志 (binlog), 然后在从库上以异步方式更新主库上的日志记录. 这意味着: 复制过程存在时延, 这段时间内, 主从数据可能不一致 (即最终一致性)

主要涉及三个线程: binlog 线程, I/O 线程和 SQL 线程

  • binlog 线程 : 负责将主服务器上的数据更改写入二进制文件 (binlog)中
  • I/O 线程 : 负责从主服务器上读取二进制日志文件, 并写入从服务器的日志中
  • SQL 线程 : 负责读取日志并执行 SQL 语句以更新数据

读写分离

主服务器用来处理写操作以及实时性要求比较高的读操作, 而从服务器用来处理读操作

读写分离常用代理方式来实现, 代理服务器接收应用层传来的读写请求, 然后决定转发到哪个服务器

MySQL 读写分离能提高性能的原因在于:

  • 主从服务器负责各自的读和写, 极大程度缓解了锁的争用;
  • 从服务器可以配置 MyISAM 引擎, 提升查询性能以及节约系统开销;
  • 增加冗余, 提高可用性

数据库优化

数据库优化的路线一般为: SQL 优化, 结构优化, 配置优化, 硬件优化. 前两个方向一般是普通开发的考量点, 而后两个方向一般是 DBA 的考量点

SQL 优化

SQL 优化是数据库优化的最常见, 最初级手段 在执行 SQL 语句, 语句中字段的顺序, 查询策略等都可能会影响到 SQL 的执行性能

执行计划

如何检验修改后的 SQL 确实有优化效果? 这就需要用到执行计划 (EXPLAIN)

使用执行计划 EXPLAIN 用来分析 SELECT 查询效率, 开发人员可以通过分析 EXPLAIN 结果来优化查询语句

比较重要的字段有:

  • select_type - 查询类型, 有简单查询, 联合查询, 子查询等
  • key - 使用的索引
  • rows - 扫描的行数

访问数据优化

减少请求的数据量:

  • 只返回必要的列 - 不要查询不需要的列, 尽量避免使用 SELECT * 语句
  • 只返回必要的行 - 使用 WHERE 语句进行查询过滤, 有时候也需要使用 LIMIT 语句来限制返回的数据
  • 缓存重复查询的数据 - 使用缓存可以避免在数据库中进行查询, 特别要查询的数据经常被重复查询, 缓存可以带来的查询性能提升将会是非常明显的

减少服务器端扫描的行数:

  • 最有效的方式是使用索引来覆盖查询 (即 WHERE 后的过滤查询字段最好是索引字段)

重构查询方式

切分查询

一个大查询如果一次性执行的话, 可能一次锁住很多数据, 占满整个事务日志, 耗尽系统资源, 阻塞很多小的但重要的查询

sql
DELEFT FROM messages WHERE create < DATE_SUB(NOW(), INTERVAL 3 MONTH);
sql
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 顺序进行查询, 这可能比随机的连接要更高效
sql
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);

SQL 语句细节

选择最有效率的表名顺序

数据库按照从右到左的顺序处理 FROM 子句中的表名, FROM 子句中写在最后的表将被最先处理

FROM 子句中包含多个表的情况下:

  • 如果多个表是完全无关系的话, 将记录和列名最少的表, 写在最后, 然后依次类推. 也就是说: 选择记录条数最少的表放在最后

如果有 3 个以上的表连接查询:

  • 如果多个表是有关系的话, 将引用最多的表, 放在最后, 然后依次类推. 也就是说: 被其他表所引用的表放在最后

例如: 查询员工的编号, 姓名, 工资, 工资等级, 部门名

emp 表被引用得最多, 记录数也是最多, 因此放在 form 字句的最后面

sql
select emp.empno,emp.ename,emp.sal,salgrade.grade,dept.dname
from salgrade,dept,emp
where (emp.deptno = dept.deptno) and (emp.sal between salgrade.losal and salgrade.hisal)
WHERE 子句中的连接顺序

数据库按照从右到左的顺序解析 WHERE 子句

因此, 表之间的连接必须写在其他 WHERE 条件的左边, 那些可以过滤掉最大数量记录的条件必须写在 WHERE 子句的之右

emp.sal 可以过滤多条记录, 写在 WHERE 字句的最右边

sql
select emp.empno,emp.ename,emp.sal,dept.dname
from dept,emp
where (emp.deptno = dept.deptno) and (emp.sal > 1500)
SELECT 子句中避免使用 *

我们当时学习的时候, "*" 号是可以获取表中全部的字段数据的

  • 但是它要通过查询数据字典完成的, 这意味着将耗费更多的时间
  • 使用*号写出来的 SQL 语句也不够直观
用 TRUNCATE 替代 DELETE

如果需要清空所有表记录, 使用 TRUNCATE 比 DELETE 执行效率高:

DELETE 是一条一条记录的删除, 而 Truncate 是将整个表删除, 仅保留表结构

使用内部函数提高 SQL 效率

**例如使用 mysql 的 concat() 函数会比使用 || 拼接速度快, 因为 concat() 函数已经被 mysql 优化过了. **

使用表或列的别名

如果表或列的名称太长了, 使用一些简短的别名也能稍微提高一些 SQL 的性能. 毕竟要扫描的字符长度就变少了

SQL 关键字大写

我们在编写 SQL 的时候, 官方推荐的是使用大写来写关键字, 因为 Oracle 服务器总是先将小写字母转成大写后, 才执行

>= 替代 >

❌ 低效方式:

sql
-- 首先定位到DEPTNO=3的记录并且扫描到第一个DEPT大于3的记录
SELECT * FROM EMP WHERE DEPTNO > 3

✔ 高效方式:

sql
-- 直接跳到第一个DEPT等于4的记录
SELECT * FROM EMP WHERE DEPTNO >= 4
用 IN 替代 OR

❌ 低效方式:

sql
select * from emp where sal = 1500 or sal = 3000 or sal = 800;

✔ 高效方式:

sql
select * from emp where sal in (1500,3000,800);
总是使用索引的第一个列

如果索引是建立在多个列上, 只有在它的第一个列被 WHERE 子句引用时, 优化器才会选择使用该索引. 当只引用索引的第二个列时, 不引用索引的第一个列时, 优化器使用了全表扫描而忽略了索引

sql
create index emp_sal_job_idex
on emp(sal,job);
----------------------------------
select *
from emp
where job != 'SALES';
SQL 关键字尽量大写

SQL 关键字尽量大写, 如: Oracle 默认会将 SQL 语句中的关键字转为大写后在执行

结构优化

数据库结构优化可以从以下方向着手:

  • 数据类型优化
  • 范式和反范式优化
  • 索引优化 - 细节请看索引和约束章节
  • 分库分表 - 细节请看分库分表章节

数据类型优化原则

  • 更小的通常更好
  • 简单就好, 如整型比字符型操作代价低
  • 尽量避免 NULL

范式和反范式

范式和反范式各有利弊, 需要根据实际情况权衡

范式化的目标是尽力减少冗余列, 节省空间

  • 范式化的优点是:

    • 减少冗余列, 要写的数据就少, 写操作的性能提高;
    • 检索列数据时, DISTINCTGROUP BY 操作减少
  • 范式化的缺点是: 增加关联查询

反范式化的目标是适当增加冗余列, 以避免关联查询

反范式化的缺点是:

  • 冗余列增多, 空间变大, 写操作性能下降;
  • 检索列数据时, DISTINCT 或 GROUP BY 操作变多;

数据库理论

函数依赖

记 A->B 表示 A 函数决定 B, 也可以说 B 函数依赖于 A

如果 {A1, A2, ... , An} 是关系的一个或多个属性的集合, 该集合函数决定了关系的其它所有属性并且是最小的, 那么该集合就称为键码

对于 A->B, 如果能找到 A 的真子集 A', 使得 A'-> B, 那么 A->B 就是部分函数依赖, 否则就是完全函数依赖;

对于 A->B, B->C, 则 A->C 是一个传递依赖

异常

以下的学生课程关系的函数依赖为 Sno, Cname -> Sname, Sdept, Mname, Grade, 键码为 {Sno, Cname}. 也就是说, 确定学生和课程之后, 就能确定其它信息

SnoSnameSdeptMnameCnameGrade
1学生-1学院-1院长-1课程-190
2学生-2学院-2院长-2课程-280
2学生-2学院-2院长-2课程-1100
3学生-3学院-2院长-2课程-295

不符合范式的关系, 会产生很多异常, 主要有以下四种异常:

  • 冗余数据: 例如 学生-2 出现了两次
  • 修改异常: 修改了一个记录中的信息, 但是另一个记录中相同的信息却没有被修改
  • 删除异常: 删除一个信息, 那么也会丢失其它信息. 例如如果删除了 课程-1, 需要删除第一行和第三行, 那么 学生-1 的信息就会丢失
  • 插入异常, 例如想要插入一个学生的信息, 如果这个学生还没选课, 那么就无法插入

范式

范式理论是为了解决以上提到四种异常

高级别范式的依赖于低级别的范式, 1NF 是最低级别的范式

第一范式 (1NF)

属性不可分

第二范式 (2NF)

  • 每个非主属性完全函数依赖于键码

  • 可以通过分解来满足

分解前

SnoSnameSdeptMnameCnameGrade
1学生-1学院-1院长-1课程-190
2学生-2学院-2院长-2课程-280
2学生-2学院-2院长-2课程-1100
3学生-3学院-2院长-2课程-295

以上学生课程关系中, {Sno, Cname} 为键码, 有如下函数依赖:

  • Sno -> Sname, Sdept
  • Sdept -> Mname
  • Sno, Cname-> Grade

Grade 完全函数依赖于键码, 它没有任何冗余数据, 每个学生的每门课都有特定的成绩

Sname, Sdept 和 Mname 都部分依赖于键码, 当一个学生选修了多门课时, 这些数据就会出现多次, 造成大量冗余数据

分解后

关系-1

SnoSnameSdeptMname
1学生-1学院-1院长-1
2学生-2学院-2院长-2
3学生-3学院-2院长-2

有以下函数依赖:

  • Sno -> Sname, Sdept, Mname
  • Sdept -> Mname

关系-2

SnoCnameGrade
1课程-190
2课程-280
2课程-1100
3课程-295

有以下函数依赖:

  • Sno, Cname -> Grade

第三范式 (3NF)

  • 非主属性不传递依赖于键码

上面的 关系-1 中存在以下传递依赖: Sno -> Sdept -> Mname, 可以进行以下分解:

关系-11

SnoSnameSdept
1学生-1学院-1
2学生-2学院-2
3学生-3学院-2

关系-12

SdeptMname
学院-1院长-1
学院-2院长-2

Mysql 存储引擎

Mysql 有多种存储引擎, 不同的存储引擎保存数据和索引的方式是不同的, 但表的定义则是在 Mysql 服务层统一处理的

简单列举几个存储引擎:

  • InnoDB - Mysql 的默认事务型存储引擎, 并提供了行级锁和外键的约束. 性能不错且支持自动故障恢复
  • MyISAM - Mysql 5.1 版本前的默认存储引擎. 特性丰富但不支持事务, 也不支持行级锁和外键, 也没有故障恢复功能
  • CSV - 可以将 CSV 文件作为 Mysql 的表来处理, 但这种表不支持索引
  • MEMORY . 所有的数据都在内存中, 数据的处理速度快, 但是安全性不高

InnoDB & MyISAM

InnoDB 和 MyISAM 是目前使用的最多的两种 Mysql 存储引擎

  • 数据结构比较:
    • InnoDB 和 MyISAM 的索引数据结构都是 B+ 树
    • MyIASM 的 B+ 树中存储的内容实际上是实际数据的地址值. 也就是说它的索引和实际数据是分开的, **只不过使用索引指向了实际数据. 这种索引的模式被称为非聚集索引. **
    • InnoDB 的 B+ 树中存储的内容是实际的数据, 这种索引有被称为聚集索引
  • 事务支持比较:
    • InnoDB 支持事务, 并提供了行级锁和外键的约束
    • MyIASM 不支持事务, 也不支持行级锁和外键
  • 故障恢复比较:
    • InnoDB 支持故障恢复
    • MyISAM 不支持故障恢复

Released under the MulanPSL2 License.