AlexiaChen / AlexiaChen.github.io

My Blog https://github.com/AlexiaChen/AlexiaChen.github.io/issues
87 stars 11 forks source link

程序员应该知道的数据库知识 #93

Open AlexiaChen opened 4 years ago

AlexiaChen commented 4 years ago

程序员应该知道的数据库知识

数据库范式与反范式

这里没啥好讲的,一般传统企业重业务,对性能,高并发要求小的开发,对数据库设计的时候尽量遵守范式,尽可能达到第三范式要求,如果是大并发,追求灵活的开发,可以适当的对范式取舍,加入违背范式的设计也没什么,互联网反范式的设计有很多。

分库分表

怎么拆分

根据业务拆分,根据读写比重拆分

分布式ID生成服务

分库之前,用数据库主键的自增就可以了,但是分库以后,需要一个全局ID服务,业界有snowflake。

拆分维度的选择

比如有个电商订单表,表里面有订单ID,用户ID,商户ID,到底是按照哪个ID进行拆分?

如果要进行多维度的查询,一般要建立一个映射表,建立辅助维度和主维度的映射关系,比如商户ID到用户ID的映射。

这个根据业务来看,不然东西太多,说不了。

Join查询问题

分库分表之后,join查询就不能用了,一般有以下方式解决,祖传秘方,如果不出意外,性能不好,一定是数据库的问题:

分布式事务

分库之后,数据库内建的事务就做不了了,一般解决办法就是优化业务,避免跨库的分布式事务,保证所有事务都落到单库中,实在无法避免,再考虑分布式事务的解决方案。

核心数据结构----B+树

B+树是B树的改进。关系型数据库在查询方面具备一些KV型没有的功能:

这些特性,一般归功于B+树。另外B树已经逐渐没啥应用了,所以就暂时不讨论B树了。

B+树的逻辑结构

数据库的主键对应了B+树的逻辑结构。

看到以上你可以感觉出来了,B+树是一颗平衡N叉树,只有叶子节点才有真正的data(记录),其他都是索引。B+树也比B树更加“矮胖”,IO次数更少,查询性能稳定等。

ss

基于这样一种数据结构,实现那几个重要特性就容易了:

你知道了以上特性,你就清楚一些数据库的明文上的死规定的原理了。

B+树的磁盘存储结构

显然,工业界的B+树实现复杂,一般都不是纯内存的实现,纯内存实现没啥价值,真正的B+树需要与IO磁盘适配,B+树难就难在磁盘存储,所以实现复杂,难度高,其中各种细节不是三言两语就能说清楚的。B+树最终要存储在磁盘上,与其他数据结构不一样,一般B+树涉及到磁盘都需要定制,很难以通用库的形式抽象出来供人们调用,很难做出一个独立的组件,以InnoDB实现的B+树为例子,我们详细展开。

对于磁盘来说,不可能一条一条读写,都是以“块”为单位进行读写,InnoDB默认是16KB块大小,通过innodb_page_size指定,这里说的块,是一个逻辑单位,不是物理磁盘扇区的物理块。块是InnoDB读写磁盘的基本单位,InnoDB每一次磁盘I/O,读取的都是16KB的整数倍数据。

无论是叶子节点,非叶子节点都会装载到Page里(一个Page对应一个块)。InnoDB为每一个Page赋予一个全局的32位编号,所以InnoDB的存储容量的上限是64TB(2^32 * 16KB)。

一个Page是16KB是什么概念?以非叶子节点来说,一个Page可以装载1000个Key(假设Key是64位整数,8个字节),意味着B+树有1000个分叉;以叶子节点来说,一个Page可以装载200条记录(记录和Key是放在一起的,所以少点,假设记录大小是100字节)。

好了,我们来估算下一个三层的B+树可以hold多大的数据量,首先一个Page装载一个B+树节点,相当于一个节点算16KB大小。

所以第三层是不能装载到内存中的,第三层叶子节点是存到磁盘上了。第一层,第二层可以装到内存里, (1 + 1000) × 16KB = 16MB,一个三层B+树可以支撑2亿条记录,依次类推4层,5层B+树。因为第三层存储在磁盘上,所以做依次查询,只需要一次磁盘IO(读取叶子节点)就可以查到数据记录。如果是更多层数的B+树,可能就需要多次磁盘IO了,因为有些层的节点只能存物理磁盘。这些存储在物理盘上的节点你可以称为索引文件了。

非主键索引

一般主键或非主键索引都对应自己的一颗B+树,它们大同小异。在InnoDB中,非主键索引的叶子节点存储的不是记录的指针,而是主键的值。所以,对于非主键索引的查询,会查询两颗B+树,先在非主键索引的B+树上定位主键,再用主键去主键索引的B+树上找到最终记录,所以直接查主键索引理论上会更快点。

因为非主键索引的值可以重复,所以一个Key可能对应多条记录,B+树的结构可能稍有不同,首先非主键索引的B+树的叶子节点存储的主键的值还有索引值,对于非叶子节点,不仅存储了索引值,同时也存储了对应的主键的最小值或最大值。

事务与锁

事务的四个隔离级别

通俗的讲,一个事务就是一个代码块,这个代码块要么不执行,要不全部执行。这是事务的原子性。事务要操作表中的数据,如果多个事务并发就会存在冲突,跟多线程操作同一份数据需要加锁是一样的。

事务并发会导致下面几个问题:

为了解决上面四个问题,数据库设置了不同的隔离级别,当然不同数据库实现各个隔离级别有差异,暂时不管。

现实中,一般采用2,3级别。

既然InnoDB默认是3级别,如何解决最后一个问题?就是丢失更新的问题,那么就涉及到乐观锁和悲观锁了

悲观锁和乐观锁

丢失更新的问题在业务场景中非常常见,数据库没有帮程序员解决这个问题,只好靠程序员自己动手了。这里的乐观锁和悲观锁是解决问题的思想手法,不是数据库中的东西。

考虑一个最简单的充钱和扣钱的金融业务场景,假设有一张表中的一条记录:

user_id          balance
1                 30

两个事务并发地对同一条记录修改,一个充钱,一个扣钱,大概如下:

事务A:

begin transaction
int b = select balance from T where user_id = 1
b = b + 50
update T set balance = b where user_id = 1
commit

事务B:

begin transaction
int b = select balance from T where user_id = 1
b = b - 50
update T set balance = b where user_id = 1
commit

如果以上两个事务串行地正确地执行,无论先后,那么最终结果都是30,30 + 50 - 50 = 30 - 50 + 50 = 30。但是如果两个事务并发执行,结果就不一定了,要不是30,要不是80或者-20。80或-20就是错误的结果,怎么导致的呢?

我们像分析多线程问题的那样,如果没有锁,会怎样呢?

事务A拿到数据30,进行计算,80。事务B显然看不到A事务的中间结果80,读出来30,进行计算-20,然后结果提交。然后事务A更新完毕提交结果,那么user_id=1的记录最终结果就是80,把-20覆盖了。这是事务A覆盖事务B。反过来事务B覆盖事务A就是-20。原因都是事务没有串行化。

要解决这个问题,有以下几种方法:

  1. 利用单条SQL语句的原子性

事务A:

begin transaction
update T set balance = balance + 50 where user_id = 1
commit

事务B:

begin transaction
update T set balance = balance - 50 where user_id = 1
commit

这样,避免了事务存在的中间结果。但是这个不实用,现实中,业务要对balance做各种计算,必然有业务代码块,中间结果,然后回写到数据库。

  1. 悲观锁

认为数据发生并发冲突的概率很大,所以读之前就上锁。利用select xxx for update语句。

事务A:

begin transaction
int b = select balance from T where user_id = 1 for update
b = b + 50
update T set balance = b where user_id = 1
commit

事务B:

begin transaction
int b = select balance from T where user_id = 1 for update
b = b - 50
update T set balance = b where user_id = 1
commit

但是悲观锁有潜在问题,比如事务A在拿到锁后,commit之前出了问题,崩溃异常什么的,会造成锁不能释放,数据库死锁。另外事务A拿到锁之后,事务B就会被阻塞,也就是select会“卡”住不返回,这样在高并发场景下会造成客户端大量的请求阻塞。所以才有了乐观锁。

  1. 乐观锁

乐观锁比较乐观,认为数据发生并发冲突的概率小,所以读之前不上锁。等写回去的时候再判断数据是否被其他事务改了,即多线程高并发里面会提到的CAS(CompareAndSet)的思路。

说白话点就是保证读写的数据记录是同一个版本。在表中需要加一个version字段:

user_id          balance      version
1                 30        

事务A:

while (!IsSuccess)
{
    begin transaction
    int b, v1 = select balance, version from T where user_id = 1
    b = b + 50
    IsSuccess = update T set balance = b, version = version + 1 where user_id = 1 and version = v1 // CAS
    commit
}

事务B:

while (!IsSuccess)
{
    begin transaction
    int b, v1 = select balance, version from T where user_id = 1
    b = b - 50
    IsSuccess = update T set balance = b, version = version + 1 where user_id = 1 and version = v1 // CAS
    commit
}

注意,balance的更新,version的比较和自增都是在一条update语句完成的,这个非常关键,不然version字段又会出现丢失更新的问题。

其实就是通过版本号知道数据是不是被修改了,被修改了update就是失败的,所以重新循环读取,再次进行业务逻辑计算,回写。当然,现实中不会无限重试,一般重试3到5次,然后操作界面提示用户稍后操作就可以了。

  1. 分布式锁

乐观锁只是预防select和update对于同一张表的同一条记录。但是如果涉及到多表select和update,那么事务A和事务B也可能会相互覆盖。乐观锁的版本号就行不通了。它针对的是同一个行记录。

这种场景需要分布式锁,就是引入一个“中心节点”作为锁,其他节点判断这个锁状态,利用Redis的一些机制可以实现分布式锁,但是在极端情况下可能会有问题,不完善。当然Redis的作者基于Redis设计出来了一个分布式锁叫RedLock,这个业界一直存在争议。还可以用Zookeeper的“瞬时节点”机制和Zab协议的强一致性来实现分布式锁,但是性能QPS不高。

songtianyi commented 3 years ago

snowflake 没更新了呀