范式与反范式
但在互联网应用中,为了性能或便于开发,违背范式的设计比比皆是,如字段冗余、字段存一个复杂的JSON串、分库分表之后数据多维度冗余存储、宽表等。如果系统是重业务性的系统,对性能、高并发的要求没有那么高,最好保证数据库的设计达到第三范式的要求。
分库分表
为什么要分?
- 业务拆分,便于职责分工,便于系统扩展
- 应对高并发,读多写少,可以通过从库、加缓存解决,不一定分库分表,读少写多,写入的QPS达到数据库的瓶颈,考虑分库分表
- 数据隔离
拆分维度的选择
- 建立映射表:建立辅助维度和主维度之间的映射关系
问题:映射表本身也需要分库分表,即使不分库分表,写入一条订单的数据也可能需要同时写两个库,属于分布式事务问题。对于这种问题,通常也只能做一个后台任务定时比对,保证订单表和映射表的数据最终一致。 - 业务双写:同一份数据,两套分库分表。例如,一套按照用户ID切分,一套按商户ID切分。
问题:同样存在多个库的分布式事务问题。 - 异步双写:还是两套表,只是业务单写。然后通过监听Binlog。同步到另外一套表上。
- 两个维度统一到一个维度:比如把用户ID作为订单ID的某几位,这样订单ID中就包含了用户ID的信息。
Join查询问题
- 把Join拆成多个单表查询,不让数据库做Join,而是在代码层对结果进行拼接。非常常见,降低慢查询的概率
- 做宽表,重写轻读,另外做join表,提前把结果join好。
- 利用搜索引擎,利用ES的搜索引擎,把数据库中的数据导入搜索引擎中进行查询,从而解决join问题。
B+树
- 范围查询
- 前缀匹配模糊查询
- 排序和分页
B+树逻辑结构
- 在叶子节点一层,所有记录的主键按照从小到大的顺序排序,并且形成了一个双向链表,叶子节点的每一个key指向一条记录。
-
非叶子节点取得是叶子节点里面key的最小值。这意味着所有非叶子节点的key都是冗余的叶子节点。同一层的非叶子节点也互相串联,形成了一个双向链表。
基于B+树的特性,会发现对于offet这种特性,其实是用不到索引的。例如select xxx where xxx limit 1000,10,从offset=1000的位置开始取10条。虽然只取了10条,但实际上数据要把前面的1000条数据都遍历才能知道offset=1000的位置在哪。合理的办法是将offset的位置换算成某个max_id,然后用where语句实现,就变成select xxx wehere id>max_id limit 10;
B+树物理结构
对于磁盘,是以”块“为单位进行读写的。InnoDB默认定义的块大小是16KB,通过innodb_page_size参数指定。InnoDB每一次磁盘IO,读取的都是16KB的整数倍的数据。无论是叶子节点,还是非叶子节点,都会装在page里。16KB如果用来装非叶子节点,一个page大概可以装1000个key,意味着B+树有1000个分叉;如果用来装叶子节点,一个Page大概可以装200条记录。基于这种估算,一个三层的B+树可以存储多少数据量呢?
第一层:一个节点是一个Page,里面存放了1000个key,对应1000个分叉
第二次:1000个节点1000page,每个page存放1000个key,对应10001000个分叉
第三层:10001000个节点,每个page存放200条记录,即是10001000200=2亿条记录,总容量是16KB10001000,约为16GB。
把第一层和第二层的索引全装入内存里,也就约16MB的内存。三层B+树就可以支撑2亿条记录,并且一次基于主键的等值查询,只需要一次IO.
page与page之间组成双向链表,每一个page头部有两个关键字段;前一个page的编号,后一个page的编号。page里面存储一条条的记录,记录之间用单向链表串联。定位到了page,也就定位到了Page里面的记录。因为Page会一次性读入内存,同一个page里面的记录可以在内存中顺序查找。
非主键索引
在InnoDB中,非主键索引的叶子节点存储的不是记录的指针,而是主键的值。且值可以重复,一个key可能对应多条记录。对于非叶子节点,不仅存储了索引字段的值,同时也存储了对应的主键的最小值。
事务与锁
事务的四个隔离级别
悲观锁和乐观锁
悲观锁,就是认为数据发生并发冲突的概率很大,所以读之前就上锁。利用select xxx for update语句。容易造成死锁以及高并发场景下会造成用户端的大量请求阻塞。
乐观锁,认为数据发生冲突的概率比较小,所以读之前不上锁,等到写回去的时候再判断数据是否被其他事务改了。类似于cas,给表结构里加一列verson字段。在实现层面,就是利用update语句的原子性实现了cas。当且仅当version为期望值时,才更新成功。同时,version也必须加1.version的比较,数据的更新,version的加1,这三件事情是在一条update语句里面完成的,这是整个事情的关键所在。
分布式锁
乐观锁的方案限制时select合update的是同一张表的同一条记录。如果是更加复杂的场景,就需要使用分布式锁来解决了。
死锁检测
以事务为顶点,以事务请求的锁为边,构建一个有向图,这个图被称为wait-for graph。死锁检测就是发现这种有向图中存在的环。检测到死锁后,数据库可以强制让其中某个事务回滚,释放掉锁,把环断开,死锁就解除了。
事务实现原理之1:Redo Log
Write-Ahead
一个事务要修改多张表的多条记录,多条记录分布在不同的page里面,对应到磁盘的不同位置。如果每个事务都直接写磁盘,一次事务提交就要多次磁盘的随机IO,性能达不到要求。解决方案:现在内存中提交事务,然后写日志(所谓的write-ahead log),然后后台任务把内存中的数据异步刷到磁盘。日志是顺序地在尾部append,从而避免了一个事务发生多次磁盘随机IO的问题。所谓的write-ahead log就是redo log。redo log写入的本身也是异步的。在事务提交后,redo log先写入内存中的redo log buffer中,然后异步地刷到磁盘上的Redo Log。
redo log物理结构
- 磁盘的读取和写入是按”块“为单位进行读取和写入。对于redo log来说,就是redo log block,为512字节。在早期的磁盘,一个扇区就是存储512个字节数据。
- redo log为一个规定大小的文件,循环使用,写到尾部之后,回到头部覆写。之所以能覆写,因为一旦Page数据刷到磁盘上,日志数据就没有存在的必要了。