【架构—基本功】数据库

范式与反范式

在一般的工程项目中,对于数据库的设计都要求达到第三范式。

范式 描述 反例
第一范式 每个字段都是原子的,不能再分解 某个字段是JSON串
第二范式 表必须有主键,主键可以是单个属性或几个属性的组合;非主键属性必须完全依赖,而不能部分依赖主键 在好友关系表中,主键是关注人ID + 被关注人ID,但表中还存储了关注人名字、头像等字段
第三范式 没有传递依赖:非主键属性必须直接依赖主键,而不能间接依赖主键 在员工表中,有个字段是部门ID,还有其他部门字段,比如部门名称

但在互联网应用中,为了性能或便于开发,违背范式的设计比比皆是,如字段冗余、存储JSON串、宽表等。如果系统是重业务性的系统,对性能、并发的要求没有那么高,最好保证数据库的设计达到第三范式的要求。

延伸阅读详解第一范式、第二范式、第三范式、BCNF范式

分库分表

为什么要分

第一个原因是做业务拆分,把一个大的复杂系统拆成多个业务子系统,之间通过RPC或消息中间件通信。这样做既便于团队分工,也便于扩展。
第二个考虑是应对高并发。但也要针对读多写少,还是读少写多的场景分别讨论。如果是读多写少,可以通过加从库、加缓存解决。如果是读少写多,或者说写入的QPS已经达到了数据库的瓶颈,这时就要考虑分库分表了。

另外一个考虑角度是数据隔离。如果把核心业务数据和非核心业务数据局放在一个库里,一旦因为非核心业务导致宕机,核心业务也会受到牵连。分开之后,区别对待,投入的开发和运维人力也不同。

分布式ID生成服务

在分库之前,数据库的自增主键可以唯一标识一条记录,在分库分表之后,需要一个全局的ID生成服务。开源的方案有Twitter的Snowflake,各大公司往往也都有自己的分布式ID生成服务。生成的ID是完全无序,还是趋势递增,或者呈更严格的单调递增,方案也不尽相同。

其他维度的查询

有了全局的ID,接下来的问题是按哪个维度拆分。比如电商的订单表,至少有三个查询维度:订单ID、用户ID、商户ID。假设按用户ID维度拆分,同一个用户ID的所有订单会落到同一个库的同一张表里。按用户ID查,可以很容易地定位到某个库的某张表,但如果按订单ID或商户ID维度查询,就很难做。

对于分库分表之后其他维度的查询,一般有一下几个方法:

1.建立一个映射表

建立辅助维度和主维度之间的映射关系(商户ID和用户ID之间的映射关系)。查询的时候根据商户ID查询映射表,得到用户ID;再根据用户ID查询订单ID。但这里有个问题:映射表本身也需要分库分表,并且分库分表的维度和订单表的分库分表维度还不同。即使映射表不分库分表,写入一条订单的时候也可能需要同时写两个库,属于分布式事务问题。对于这种问题,通常也只能做一个后台任务定时比对,保证订单表和映射表的数据最终一致。

2.业务双写

同一份数据,两套分库分表。一套按用户ID切分,一套按商户ID切分。同样,存在写入多个库的分布式事务问题。

3.异步双写

还是两套表,只是业务单写。然后通过监听Binlog,同步到另外一套表上。

4.两个维度统一到一个维度

把订单ID和用户ID统一成一个维度,比如把用户ID作为订单中的某几位,这样订单ID中就包含了用户ID信息,然后按照用户ID分库,当按订单ID查询的时候,截取出用户ID,再按用户ID查询。或者订单ID和用户ID中有某几位是相同的,用这几位作为分库维度。

Join查询问题

分库分表之后,Join查询就不能用了。针对这种情况,一般有下面几种解决方法:

1.把Join拆成多个单表查询

这种做法非常常见,因为数据库全是单表查询,也大大降低了产生慢查询的概率。

2.做宽表,重写轻读

很多时候会有这样的情况:需要把Join的结果分页,这需要利用MySQL本身的分页功能。对于这种不得不Join的情况,可以另外做一个Join表,提前把结果Join好。

3.利用搜索引擎

对于第二种方法中的场景,还可以利用类似ES的搜索引擎,把数据库中的数据导入搜索引擎中进行查询,从而解决Join问题。

分布式事务

做了分库分表之后,纯数据库的事务就做不了了。一般的解决办法是优化业务,避免跨库的事务,保证所有事务都落到单库中。

如果实在无法避免,需要分布式事务的解决方案。分布式事务是个系统性的问题,后面会专门论述。

延伸阅读
sharding:谁都能读懂的分库、分表、分区
数据库分库分表思路

B+树

关系型数据库在查询方面有一些重要特性,是KV型数据库所不具备的,比如:

  1. 范围查询。
  2. 前缀匹配模糊查询。
  3. 排序和分页。

这些特性的支持,要归功于B+树这种数据结构。

B+树逻辑结构

数据库对应的B+树逻辑结构有几个关键特征:

  1. 在叶子节点一层,所有的索引键按照从小到大的顺序排列,并且形成了一个双向链表。
  2. 非叶子节点取的是叶子节点里面Key的最小值。非叶子节点只存储索引值,数据记录(二级索引则为主键值)全存储在叶子节点上。同一层的非叶子节点也形成一个双向链表。

不过,基于B+树的特性,会发现对于offset这种特性,其实是用不到索引的。比如每页显示10条数据,要展示第101页,实际上数据库要把前面的1000条数据都遍历才能知道offset的位置。对于这种情况,一种可行的办法是不要用offset,而是把offset的位置换算成大于某个ID的语句来实现。

B+树物理结构

下面以MySQL中最常用的InnoDB引擎为例,看一下如何实现B+树的存储。
对于数据库来说,都是以页为单位进行读写的。InnoDB默认定义的页大小是16KB,通过innodb_page_size参数指定。无论叶子节点,还是非叶子节点,都会装在Page里。InnoDB为每个Page赋予一个全局的32位的编号,所以InnoDB的存储容量上限是64TB。

一个Page如果用来装非叶子节点,大概可以装1000个;如果用来装叶子节点,大概可以装200条记录(列比较少且空间比较小)。基于这种估算,一棵三层的B+树可以存储2亿条记录,总容量约16GB。
把第一层和第二层的索引全装入内存里,约占用16MB的内存。三层B+树就可以支撑2亿条记录,并且一次基于主键的等值查询,只需要一次物理I/O。

B+树的Page与Page之间组成双向链表,每一个Page头部都有前后Page的编号。Page里面存储一条条记录,记录之间用单向链表串联。对于记录来说,定位到了Page,也就定位到了Page里面的记录。因为Page会一次性读入内存,同一个Page里面的记录可以在内存中顺序查找。

非主键索引

对于非主键索引,每个也对应着一棵B+树。InnoDB的非主键索引的每个叶子节点存储了主键值,非叶子节点不仅存储了索引字段的值,同时也存储了对应的主键的最小值。

延伸阅读
为什么MySQL数据库索引选择使用B+树?
MySQL索引原理

事务与锁

事务的四个隔离级别

通俗地讲,事务就是一个代码块,要么不执行,要么全都执行。事务要操作数据,事务与事务之间会存在并发冲突。因而可能会导致下面几类问题。

问题 描述
脏读 某个事务读取到了其他事务未提交的记录
不可重复读 在同一个事务里面,因为其他事务的update操作,导致两次读取同一行记录,但结果不一样
幻读 在同一个事务里面,因为其他事务的insert/delete操作,导致同样的select语句,执行两次,返回的记录条数不一样
丢失更新 两个事务同时修改同一条记录,事务A的修改被事务B覆盖了

注:这里的丢失更新指的是逻辑意义上的丢失更新,如查询记录存入本地内存后再进行更新,而非数据库意义上的丢失更新,因为在任何隔离级别下,对于行的DML操作,都需要对行或其他粗粒度级别的对象加锁。

为了解决上面几类问题,数据库设置了不同的事务隔离级别。不同数据库在事务隔离级别的定义和实现上会有差异,SQL标准定义的隔离级别如下表。

级别 解决问题
Read Uncommitted 上述四个问题均未解决
Read Committed 解决了脏读
Repeatable Read 解决了脏读和不可重复读,在此级别InnoDB通过Next-Key锁解决了幻读,也是InnoDB默认的隔离级别
Serialization 串行化,解决所有问题
丢失更新的解决方法(悲观锁和乐观锁)

你可能感兴趣的:(架构)