范式与反范式
在一般的工程项目中,对于数据库的设计都要求达到第三范式。
范式 | 描述 | 反例 |
---|---|---|
第一范式 | 每个字段都是原子的,不能再分解 | 某个字段是JSON串 |
第二范式 | 表必须有主键,主键可以是单个属性或几个属性的组合;非主键属性必须完全依赖,而不能部分依赖主键 | 在好友关系表中,主键是关注人ID + 被关注人ID,但表中还存储了关注人名字、头像等字段 |
第三范式 | 没有传递依赖:非主键属性必须直接依赖主键,而不能间接依赖主键 | 在员工表中,有个字段是部门ID,还有其他部门字段,比如部门名称 |
但在互联网应用中,为了性能或便于开发,违背范式的设计比比皆是,如字段冗余、存储JSON串、宽表等。如果系统是重业务性的系统,对性能、并发的要求没有那么高,最好保证数据库的设计达到第三范式的要求。
分库分表
为什么要分
第一个原因是做业务拆分,把一个大的复杂系统拆成多个业务子系统,之间通过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型数据库所不具备的,比如:
- 范围查询。
- 前缀匹配模糊查询。
- 排序和分页。
这些特性的支持,要归功于B+树这种数据结构。
B+树逻辑结构
数据库对应的B+树逻辑结构有几个关键特征:
- 在叶子节点一层,所有的索引键按照从小到大的顺序排列,并且形成了一个双向链表。
- 非叶子节点取的是叶子节点里面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 | 串行化,解决所有问题 |