跳转至

事务

https://www.liaoxuefeng.com/wiki/1177760294764384/1179611198786848

所谓的事务(transaction),就是把多条语句作为一个整体进行操作,这些操作要么全部成功,要么全部失败,是一个不可分割的集合。

ACID 特性

事务具有 ACID 4 个特性:

AID 是实现一致性(C)这个目标的手段

  • Atomicity 原子性,将所有SQL作为原子工作单元执行,要么全都执行,要么全不执行;
  • Consistency 一致性,事务完成后,所有数据的状态都是一致的,即 A 账户只要减去了100,B 账户则必定加上了 100;
  • Isolation 隔离性,如果有多个事务并发执行,每个事务作出的修改必须与其他事务隔离;
  • Durability 持久性,即事务完成后,对数据库数据的修改被持久化存储,不会再因为任何原因而导致其修改的内容被撤销或丢失。

显式事务

单条 SQL 语句,其实也是作为一个事务被执行的,属于数据库系统默认的行为,这种事务被称为「隐式事务」

而通常我们所说的事务是「显式事务」

BEGIN;  -- 开启事务,或者 start transaction
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- user1 的余额 -100
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- user2 的余额 +100
COMMIT;  -- 提交事务,失败会回滚

-- 可以主动让事务失败回滚
ROLLBACK

有些客户端连接框架会默认连接成功后先执行一个 set autocommit=0 的命令,则不再需要 begin 显示启动事务,但这样会导致事务不再自动 commit,遇到长连接,就会一直处于同一个长事务中。

要避免使用长事务,除了占用锁资源,产生的回滚日志会占用大量存储空间。

-- 查找持续时间超过 60s 的事务
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60

每条记录在更新的时候都会同时记录一条回滚操作,假设一个值从 1 被按顺序改成了 2、3、4,就会有类似下面的回滚日志记录

20241017230926

即同一条记录在系统中可以存在多个版本,也就是数据库的多版本并发控制(MVCC),可以将值从 4 依次回滚到 1。

当没有事务再需要用到这些回滚日志时,回滚日志才会被删除。

隔离级别

当数据库上有多个事务同时执行的时候,就可能出现脏读、不可重复读、幻读的问题,为了解决这些问题,就有了「隔离级别」的概念。

20241017220024

SQL 标准定义了 4 种隔离级别,不同隔离级别下 V1,2,3 读到的值是不一样的

读未提交 Read Uncommitted
事务 B 还没有提交时,变更结果就可以被事务 A 读到,所以 V1=V2=V3=2

读提交 Read Committed
事务 B 提交后,变更结果才可以被事务 A 读到,所以 V1=1, V2=V3=2

可重复读 Repeatable Read
事务在执行期间所看到的数据,前后总是一致的,即事务 A 提交前读到的值都一样,所以 V1=V2=1, V3=2
另外,对其他事务也是不可见的。
适用场景:比如计算上个月余额和当前余额的差值,是否与本月的账单明细一致,这个过程中若不希望用户的新交易影响校对结果,就需要使用可重复读隔离级别。

串行化 Serializable
读或写同一行记录时,会加读锁或写锁。后访问的事务必须等前一个事务执行完成,才能继续执行。
事务 B 将值从 1 修改到 2 时,会加写锁,作为后执行的事务,需要等待事务 A 提交后才可以继续执行。所以 V1=V2=1, V3=2

从视图的角度来理解不同隔离级别

读未提交,直接返回记录上的最新值,没有视图的概念
读提交,SQL 语句开始执行时创建视图
可重复读,事务启动时会创建视图,整个事务存在期间都用这个视图
串行化,使用加锁的方式避免并行访问

Oracle 默认的隔离级别是「读提交」,InnoDB 默认的隔离级别是「可重复读」,所以跨数据库迁移时一定要保证隔离级别一致

-- 查看当前事务隔离级别
SHOW VARIABLES LIKE 'transaction_isolation';
SHOW VARIABLES LIKE 'tx_isolation';  -- v8.0+
-- 查看全局事务隔离级别
SHOW GLOBAL VARIABLES LIKE 'tx_isolation';

-- 设置当前会话隔离级别
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- 设置全局隔离级别
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

不同隔离级别会产生的问题

20211103154025

  • 脏读(Dirty Read),一个事务会读到另一个事务未提交的更新,如果另一个事务回滚,那么当前事务读到的数据就是脏数据。
  • 不可重复读(Non-Repeatable Read),在一个事务内,多次读同一数据,在这个事务还没有结束时,如果另一个事务恰好修改了这个数据,那么,在第一个事务中,两次读取的数据就可能不一致。
  • 幻读(Phantom Read),在一个事务中,第一次查询某条记录,发现没有,但是,当试图更新这条不存在的记录时,竟然能成功,并且,再次读取同一条记录,它就神奇地出现了。

数据库锁设计的初衷是处理并发问题

根据加锁的范围,MySQL 里面的锁大致可以分成:全局锁、表级锁、行锁

全局锁

全局锁就是对整个数据库实例加锁,MySQL 提供了一个加全局读锁的方法,命令是: Flush tables with read lock(FTWRL)

此时数据库将变为只读状态,所有更新数据库的操作都会被阻塞,通常用于数据库逻辑备份时

如果数据库引擎支持隔离级别,则这个场景使用可重复读隔离级别事务是更友好,因为备份期间仍可以更新数据库,并且可以保持数据一致性,比如官方自带的逻辑备份工具 mysqldump 就是这样做的

表级锁

表锁一般是在数据库引擎不支持行锁的时候才会被用到(比如 MyISAM),同一张表上任何时刻只能有一个更新在执行,会影响到业务并发度

  • 表锁
-- 线程 A 中执行,t1 设为读锁,t2 设为写锁
lock tables t1 read, t2 write;
-- 持有读锁的线程(线程 A)可以读取表 t1 的数据,其他线程在这个读锁期间不能对 t1 进行写入操作,但可以读取
-- 持有写锁的线程(线程 A)可以对表 t2 进行读写操作,其他线程在这个写锁期间不能对 t2 进行任何操作,包括读取和写入

-- 解锁
unlock tables
-- 如果线程 A 没有显式地解锁,将保持到连接关闭
  • 元数据锁(meta data lock,MDL)

MDL 不需要显式使用,在访问一个表的时候系统默认自动加的,在 MySQL 5.5 版本中引入

当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。

所有对表的增删改查操作都需要先申请 MDL 读锁,说要要避免长事务,因为事务不提交就会一直占用 MDL 锁,导致线程爆掉

行级锁

在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放,这个就是两阶段锁协议。

所以如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。

1. 从顾客 A 账户余额中扣除电影票价
2. 给影院 Y 的账户余额增加这张电影票价
3. 记录一条交易日志

比如上述场景,需要放到一个事务中执行,考虑到还会有其他顾客购买影票
那么会产生冲突的语句就是 2,所以应该把操作 2 加行级锁并放到最后,即:3->1->2,来减少事务间的锁等待,提升并发度

当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会出现死锁

比如下图,事务 A 在等待事务 B 释放 id=2 的行锁
事务 B 在等待事务 A 释放 id=1 的行锁,就进入了死锁状态

20241124173028

出现死锁有两种策略

  • 策略1,进入等待,直到超时

innodb_lock_wait_timeout 的默认值是 50s,这对于线上服务是无法接受的,但若改小这个值,又会影响正常的等待

  • 策略2,发起死锁检测,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行

将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑

但如果线程过多,死锁检测要消耗大量的 CPU 资源,导致每秒执行不了几个事务

所以要控制并发度,而且不能只在客户端上去控制,因为客户端的数量不确定,即使每个客户端控制几个,多个客户端加起来也会有很多,所以最好是在服务端去控制,如果有中间件,可以考虑在中间件实现,基本思路就是,对于相同行的更新,在进入引擎之前排队。这样在 InnoDB 内部就不会有大量的死锁检测工作了。