数据库底层 - 2
锁的类别
全局锁
锁整个数据库
表锁
锁整张表。适合读多写少,全表扫描或表结构更改的场景。InnoDB中无索引的 update 或 delete 可能会导致锁升级为表锁。
意向锁
表示事务打算对表中某些数据加锁,但不会直接锁定。比如在实行select for update操作时,会先在表上加一个意向锁,然后在目标行上加排他锁该锁由 InnoDB 自动管理。当事务需要添加行锁时,会先在表上添加意向锁。这样当要添加表锁时,可以通过查看表上的意向锁,快速判断是否有冲突。
意向锁主要用于协同其他锁进行工作。避免为了添加表锁而不得不扫描全表的情况。而共享锁允许多个事务同时加从而读取同一数据。但排他锁和其他共享锁或排他锁均互斥。
当前持有的锁 X(排他锁) S(共享锁) IX(意向排他) IS(意向共享) X 否 否 否 否 S 否 是 否 是 IX 否 否 是 是 IS 否 是 是 是
还存在 SIX 这种“意向共享 + 排他锁”,用于“既要读整张表,又要修改某些行”,与其他的锁都互斥。
行锁
行锁的底层是通过给索引加锁实现的,InnoDB 只有在通过索引条件检索数据时,才能使用行级锁。
记录锁
这是行锁最基本的表现形式,当我们使用唯一索引或主键索引进行等值查询时,MySQL会为该记录自动添加排他锁,禁止其他事务读取或修改锁定记录间隙锁
进行范围查询时,锁定记录间的“间隙”,防止其他事务在该范围内插入新纪录,防止幻读在执行
for updatelock in share mode等加锁语句,且查询条件是范围查询时,就会自动加间隙锁临键锁
这是前两者的结合体,锁索引记录和索引记录之间的空隙该锁的间隙是一个左开右闭区间。MySQL默认的行锁就是临键锁。
使用命令 show engine innodb status 可以查看死锁信息
事务
事务遵循 ACID 特性,即原子性、一致性、隔离性和持久性。
原子性
Atomicity
要么全部完成,要么全部不完成。一般通过 undo log 实现一致性
Consistency
确保事务从一个一致的状态转移到另一个一致的状态。例如银行的转账中,无论转没转成功,最后转账双方的总金额应该是不变的。
隔离性
Isolation
并发执行的事务是彼此隔离的,一个事务的执行不会被其他的事务干扰。
隔离级别详情需要看下面内容持久性
Durability
事务一旦提交,它对数据所做的更改就是永久性的。一般通过redo log实现。一般一个数据页大小为16kb.如果发生崩溃,可能会导致数据页不完整。为了解决值写入部分的问题,MySQL采用了双写机制,脏盘刷页时,先将数据页写入到一个双写缓冲区内,这是一个2M的连续空间,然后再将其写入到磁盘的实际位置。
事务一般有如下阶段
- 活动阶段:事务开始执行。
- 部分提交状态:最终语句执行完毕后,但还没有 commit 的状态。
- 失败状态:发现无法正常执行后。
- 终止状态:事务回滚后,数据库恢复至事务开始前的状态。中止后一般会重启事务或取消事务。
- 提交状态:事务执行成功。
隔离级别
SQL标准定义了四种事务隔离级别,用来平衡事务的隔离性和并发性能
- 读取未提交,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。
- 读取已提交,允许读取已提交的数据。Oracle或SQL Server默认的隔离级别,可能导致幻读和不可重复读。
- 可重复读,对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己修改。MySQL InnoDB 的默认隔离级别。
- 可串行化,所有事务依次逐个执行,这样事务间就不会造成干扰。可以防止脏读、不可重复读和幻读。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读取未提交 | 是 | 是 | 是 |
| 读取已提交 | 否 | 是 | 是 |
| 可重复读 | 否 | 否 | 是,但在InnoDB中很难发生 |
| 可串行化 | 否 | 否 | 否 |
不同的隔离级别可能会产生不同的不一致问题
- 脏读,及一个事务读到了另一个尚未提交的事务修改的数据。该数据可能会回滚,因此可能根本不存在。那么第一个事务读到的数据就是“脏”的。
- 不可重复读,在同一个事务中,两次读取同一条记录,得到的结果不一致。也就是说,在事务执行期间,它正在处理的数据被其他已提交的事务修改了。
- 幻读,在同一个事务中,两次执行相同的条件查询,第二次查询看到了第一次查询没看到的新的“幻影”行。也就是说,在事务执行期间,有其他 已提交的事务插入了新的、满足查询条件的记录。
不可重复读的重点在于修改了已有的行,幻读的重点在于新增了原本不存在的行。
解决幻读的方法有很多,主要有以下三种
- 将隔离级别设置为可串行化
- 在可重复读的事务级别下给事务操作的这张表添加表锁
- 在可重复读的事务级别下,给事务操作的这张表添加
Next-key Lock(Record Lock+Gap Lock)
InnoDB通过MVCC机制可以在一定程度上避免幻读。
在可串行化调度中,多个事务的并发执行结果为这些事务按某种次序串行化执行的结果。要实现这个效果,就必须满足两段锁协议 (2PL)
- 获得封锁,在对任何数据进行读写操作前,事务首先要获得对该数据的封锁。
- 释放封锁,在释放一个封锁后,事务不再申请和获得任何其他封锁。
MVCC
MVCC机制全称为 Multi-Version Concurrency Control (多版本并发控制)。它是通过在每个数据行上维护多个版本的数据来实现的。
当一个事务执行读操作时,它会使用快照读取,读取该事务开始前的数据快照,这样,读操作就不会被写操作阻塞,反之亦然。
读操作分为两种:当前读和快照读。
- 快照读指事务在执行
select查询时,InnoDB不会直接读取当前最新数据,而是根据事务开始时生成的快照去判断每条记录的可见性,从而读取符合条件的历史版本 - 当前读指事务在进行更新、插入和删除操作时,其本质上也要进行读操作。为了保证修改的是最新的数据,并防止并发冲突,InnoDB必须读取最新版本的数据并加锁。
在InnoDB中,每一行数据都会自动添加几个隐藏的字段,对MVCC较为重要的有
DB_TRX_ID事务id,表示最后一次修改本行数据的事务idDB_ROLL_PTR回滚指针,指向改行数据上一个历史版本在undo log中的地址。该指针不断往前指,形成一条版本链,用于支持快照读DB_ROW_ID行id,用于在没有主键时自动生成聚簇索引
undo log 除了为回滚提供数据,还用于MVCC,存储数据的历史版本。每一行的DB_ROLL_PTR会直接指向其各个历史版本。
一个Read View快照中主要包含以下内容
m_ids生成快照时系统中所有活跃事务的事务id列表min_trx_idm_ids中最小的事务id,小于这个id的均为可见max_trx_id生成快照时,系统应该分配给下一个事务的idcreator_trx_id创建这个快照的事务id
当事务需要读取一行数据时,它会从最新的版本开始,顺着版本链依次判断每个版本是否对自己可见
- 若
DB_TRX_ID=creator_trx_id,说明这是当前事务自己修改的,可见 - 若
DB_TRX_ID<min_trx_id,说明这个版本在快照创建前就已经提交了,可见 - 若
DB_TRX_ID>=max_trx_id,说明这个版本是在快照创建后才开启的事务修改,不可见 - 若
min_trx_id<DB_TRX_ID<max_trx_id,则需要判断DB_TRX_ID是否在m_ids(活跃事务列表)中。- 在则说明该版本的事务在生成快照时还没有提交,不可见
- 不再则说明修改该版本的事务在生成快照时已提交,可见
- 若某版本对当前事务不可见,则顺着版本链找到上一版本,重复上述判断规则,直到找到可见的版本为止

在不同的隔离级别下
- 读已提交
每一次执行select语句时,都会重新生成一个新的快照。因此它能读取到最新已提交的数据,避免了脏读,但无法避免不可重复读和幻读 - 可重复读
只有第一次执行select语句时才会生成一个快照,并且这个快照贯穿整个事务的始终。因此事务中后续的所有读操作都是在同一个快照下进行,避免了不一致问题
而其他的级别不需要MVCC机制
MVRC
Oracle 中使用的多版本控制工具。
当修改一条数据时,Oracle 并不会立刻覆盖旧数据。相反,它会将数据修改前的原始值保存到回滚段 (Undo Segment) 中,同时,数据行头会修改它的事务 ID。当另一个查询在修改后开始执行时,Oracle 进行判断,若数据行未修改则直接读取,若数据行已被其他事务修改但未提交,或是在查询开始后才提交,那么查询不会去读取这个数据,而是从回滚段中找出并重建出查询开始时刻该数据的正确版本。因此其默认隔离级别是读已提交。
高可用
读写分离
可以对数据库的读写操作分散到不同的节点上,一个节点为主,负责写操作,多个从。负责读操作。应用层通过中间件(小型项目自己处理,中型项目通过spring+多数据源插件,AOP注解自动路由,大型项目通过中间件,如MyCat、Atlas、shardingsphere 或 MySQL Router)进行代理,自动路由请求。
主库将数据变更通过binlog同步到从库,从而保证数据一致性。
- 主库将数据库中数据的变化写入binlog
- 从库链接主库
- 从库会创建一个IO线程向主库请求更新的binlog
- 主库会创建一个binlog dump线程来发送binlog,从库中的IO线程负责接收
- 从库的IO线程将接收的binlog写入到relay log中
- 从库的SQL线程读取relay log同步数据到本地,即再执行一遍SQL
但是在接收binlog的过程中可能会造成主从延迟,对某些业务场景可能无法容忍,一般有三种解决方案。
- 对一致性高的数据走主库查询
- 对非关键业务允许短暂数据不一致,在业务端阻塞
- 采用半同步复制,主库在事务提交时,要等至少一个库确认收到binlog(但不要求执行完成)才算提交成功
造成主从延迟的原因可能有很多种,例如从库机器性能比主库差,从库处理请求太多,事务过大,从库太多,网络延迟,等等。
分库分表
分库分两种
- 水平分库,一个表按一定的规则拆分到不同的库中
- 垂直分库,按照业务模块将不同的表拆分到不同的库中
分表也可以垂直分表或垂直分表。其中水平分表一般和水平分库同时出现。往往在单表数据达到500万级,或者数据库占用空间过大时,就可以考虑水平分表了。
数据的分片有以下几种方案
- 哈希分片:求指定分片键的哈希,然后根据哈希值确定数据应该被放置在哪个表中
适合随机读写的场景,但对动态伸缩(动态新增表或库)不友好 - 范围分片:按照特定的范围区间来分配数据
适合经常进行范围查找且数据分布均匀的场景,不适合随机读写的场景 - 映射表分片(路由分片):使用一个单独的映射表来存储分片键和分片位置的对应关系。映射表的策略可以支持任何类型的分片算法
可以灵活调整分片规则,但需要维护额外的表,增加了查询的开销和复杂度
可以使用ShardingSphere插件来实现分库分表。
不停机扩容分为三个阶段
- 新旧库同时写入,确保数据实时同步;可以借助消息队列实现异步补偿,幂等避免重复写入。读操作仍然走旧库
- 通过某些脚本将旧库的历史同步到新库,关键业务在查询时同时查询新旧库,进行数据校验,确保一致性
- 确保新库数据一致性后,逐步将读请求切换到新库,然后下线旧库
分库分表不是分得越细越好,进行分库分表后
- 同一个数据库中的表分布在了不同的数据库中,导致无法使用join联表查询
- 若单个操作涉及多个数据库,那么数据库自带的事务就无法满足需求了,必须引入分布式事务
- 自增ID容易引发冲突,需要引入分布式ID,使用全局唯一方案