一文讲清:数据库事务、锁、隔离级别
事务是数据库执行的一组操作(如多条SQL)的逻辑单元,要么全部成功,要么全部失败,不存在中间状态。
一、数据库事务的4个基本特征
数据库事务具有以下4个基本特征,分别是:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Duration),简称ACID。
- 原子性(Atomicity):事务里的操作是一个整体,要么全部成功,要么全部失败。
- 一致性(Consistency):事务前后,数据必须符合所有约束规则(如金额不能为负)。
- 隔离性(Isolation):多个事务并发执行时,彼此像串行一样隔离,防止数据混乱。
- 持久性(Duration):事务一旦提交,数据永久保存,即使断电也不丢失。
举例:银行转账(A给B转100元)
操作包含两步:① A账户扣100元;② B账户加100元。
原子性:如果第②步服务器宕机,第①步的扣款会回滚,A的钱一分不少。
一致性:转账前后,A+B的总金额始终不变(都是1000元),不会凭空多出或消失。
隔离性:转账过程中(A已扣但B未加),C查总账看到的是转账前的旧状态,不会看到“钱少了还没到”的脏数据。
持久性:转账成功后,银行系统提示“到账”,即使立刻停电,重启后B的账户里也永久多了这100元。
二、锁机制介绍
乐观锁:假定每次操作都不会发生冲突,所以不加锁,只在提交更新时检查数据是否被修改过,若被改过则拒绝提交并重试。
悲观锁:假定每次读写数据都会发生冲突,所以在操作前先加锁,阻塞其他事务,直到操作完成才释放。
乐观锁的常见实现(实际不加锁,靠版本控制):
- 版本号机制(最常用):表中增加
version字段,更新时SET version=version+1 WHERE id=? AND version=旧值,若影响行数为0则说明已被改过。 - 时间戳机制:用
last_updated时间戳代替版本号,判断是否被更新。 - CAS(比较并交换)思想:在SQL中直接通过条件判断状态,如
UPDATE stock SET num=num-1 WHERE id=1 AND num>0。
悲观锁的常见实现(由数据库提供):
- 排他锁(X锁/写锁):用于数据修改操作,例如 INSERT、UPDATE 或 DELETE。确保不会同时对同一资源进行多重更新。锁定后其他事务无法修改或加锁。
-- NOWAIT:立即返回错误而不等待 -- SKIP LOCKED:跳过已被锁定的行 SELECT ... FOR UPDATE [NOWAIT | SKIP LOCKED]扩展:MySQL-select ... for update语句详解
- 共享锁(S锁/读锁):用于不更改或不更新数据的操作(只读操作),如 SELECT 语句,允许其他事务读取(加共享锁),但禁止修改。
SELECT ... LOCK IN SHARE MODE扩展:锁定查询结果 LOCK IN SHARE MODE
按粒度划分:表锁(锁整张表)和行锁(InnoDB 仅锁索引行),还有间隙锁(Gap Lock)防止幻读等。
补充:死锁是什么?
死锁是指两个或多个事务在并发执行时,互相持有对方需要的资源,并等待对方释放,导致所有事务永久阻塞、无法继续执行的局面。
经典例子(相互等待):事务A先锁住表1,试图去锁表2;事务B先锁住表2,试图去锁表1。
A等B释放表2,B等A释放表1,两人谁也不松手,形成循环等待,永远卡死。
如何解决死锁?
解决思路分为预防、超时和检测回滚三个层面:
1. 预防(代码层面,最根本)
固定访问顺序:约定所有事务都按相同顺序访问资源(如先更新表1,再更新表2),破坏“循环等待”条件。
尽量缩短事务:将大事务拆小,减少持锁时间,降低冲突概率。
2. 超时机制(数据库设置)
设置锁等待超时参数(如MySQL的
innodb_lock_wait_timeout),事务等待超过指定时间(如50秒)则主动放弃并回滚,避免无限等待。
3. 死锁检测与自动回滚(数据库自带)
InnoDB等存储引擎会实时检测等待图,一旦发现环状死锁,立即选择回滚“代价较小”的事务(如undo记录少的事务),让其释放锁,让另一个事务继续执行。被回滚的事务会收到错误码,由应用程序捕获后重试。
注:什么是死锁和如何解决死锁
三、丢失更新
在多个事务同时操作数据库的情况下,会引发丢失更新的场景。比如,电商有一种商品,在疯狂抢购(如秒杀)中,会出现多个事务同时访问商品库存的场景,这样就会产生丢失更新。一般而言,存在两种类型的丢失更新,接下来让我们从一个例子中了解他们。
假设一种商品的库存数量还有100,每次抢购都只能抢购1件商品,那么在抢购中就可能出现下表中的情况。
| 时刻 | 事务1 | 事务2 |
| T1 | 初始库存100 | 初始库存100 |
| T2 | 扣减库存,余99 | |
| T3 | 扣减库存,余99 | |
| T4 | 提交事务,库存变为99 | |
| T5 | 回滚事务,库存100 |
从上表中可以看到,T5时刻事务1回滚,导致原本库存为99的变为了100,这导致事务2的提交结果丢失了。
类似地,对于这样一个事务回滚另一个事务提交而引发的数据不一致的情况,我们称之为第一类丢失更新。(既事务回滚导致的另一个事务的更新丢失)
对于第一类丢失更新,因为目前大部分数据库已经解决了,所以我们不对此进行深入讨论。接下来,我们来看看什么是第二类丢失更新。
| 时刻 | 事务1 | 事务2 |
| T1 | 初始库存100 | 初始库存100 |
| T2 | 扣减库存,余99 | |
| T3 | 扣减库存,余99 | |
| T4 | 提交事务,库存变为99 | |
| T5 | 提交事务,库存变为99 |
注意T5时刻提交的事务。因为在事务1中,无法感知事务2的操作,这样它就不知道事务2已经修改过了数据,因此它依旧认为只是发生了一笔业务,所以库存变成了99,这导致事务2提交的结果丢失。
像这样,多个事务都提交而引发的丢失更新称为第二类丢失更新。(即事务A覆盖事务B已经提交的数据,造成的丢失更新)
为了克服第二类丢失更新,数据库提出了事务之间的隔离级别的概念,下面,让我们详细看看事务的隔离级别。
四、事务的隔离级别
1、未提交读(READ_UNCOMMITTED)
未提交读是最低的隔离级别,其含义是允许一个事务读取另外一个事务没有提交的数据。这是一种危险的隔离级别,所以一般在我们实际的开发中应用不广,但是它的优点在于并发能力高,适用于那些对数据一致性没有要求而追求高并发的场景,他的最大坏处是出现脏读(一个事务读取了另一个尚未提交(未Commit)的事务修改过的数据)。
举例:卖家A在事务中把房价从500万改成400万(未提交)。买家B去查房价,看到400万,大喜过望开始筹钱。结果卖家A反悔,执行了回滚(Rollback),价格恢复成500万。
结果:B读到的400万是根本不存在的“脏数据”,纯属白高兴一场。
| 时刻 | 事务1 | 事务2 | 备注 |
| T0 | 商品库存初始为2 | ||
| T1 | 读取库存为2 | ||
| T2 | 扣减库存 | 此时库存为1 | |
| T3 | 扣减库存 | 此时库存为0,因为读取到事务1未提交的库存数据 | |
| T4 | 提交事务 | 库存保存为0 | |
| T5 | 回滚事务 | 因为第一类丢失更新已经解决,所以不会回滚为2。因此此时库存为0 |
上表的T3时刻,因为采用未提交读,所以事务2可以读取事务1未提交的库存数据(库存为1),这里当它扣减库存后提交,数据库将保存此数据为0。当事务1回滚时,结果仍然为0。很显然,这是错误的,发生了脏读现象。
脏读:A事务读取B事务尚未提交的更改数据,并在这个数据的基础上进行操作,这时候如果事务B回滚,那么A事务读到的数据是不被承认的。
为了克服脏读的问题,数据库隔离级别提供了读写提交(READ COMMITTED)
2、读写提交(READ COMMITTED)
读写提交隔离级别,是指一个事务只能读取另外一个事务已经提交的数据,不能读取未提交的数据。
| 时刻 | 事务1 | 事务2 | 备注 |
| T0 | 商品库存初始为2 | ||
| T1 | 读取库存为2 | ||
| T2 | 扣减库存 | 此时库存为1 | |
| T3 | 扣减库存 | 库存为1,因为是读写提交隔离级别,读取不到事务1未提交的库存数据 | |
| T4 | 提交事务 | 库存保存为1 | |
| T5 | 回滚事务 | 因为第一类丢失更新已经解决,所以不会回滚为2。因此此时库存为1,结果正确 |
在T3时刻,由于采用了读写提交的隔离级别,所以事务2不能读取到事务1中未提交的数据,因此解决了脏读问题。但是,读写提交也会导致下面的问题:
| 时刻 | 事务1 | 事务2 | 备注 |
| T0 | 商品库存初始为1 | ||
| T1 | 读取库存为1 | ||
| T2 | 扣减库存 | 事务未提交 | |
| T3 | 读取库存为1 | 认为可扣减 | |
| T4 | 提交事务 | 库存保存为0 | |
| T5 | 扣减库存 | 失败,因为此时库存为0,无法扣减 |
在T3时刻事务2读取库存时候,因为事务1未提交事务,所以读出的库存为1,于是事务2认为当前可扣减库存。当时当T4时刻,事务1提交事务后,在T5时刻,事务2会发现扣减失败。像这样的问题,叫做不可重复读。
不可重复读:在一个事务内,两次读取同一行数据,由于中间被其他事务修改(UPDATE)并提交了,导致两次读到的值不一样。
举例:财务事务A在上午9点查询员工张三的工资,显示为5000元。事务进行中,老板事务B在9点05分给张三涨薪到6000元并提交。9点10分,财务事务A再次查询张三工资,发现变成了6000元。
结果:同一个事务里,同一行数据前后不一致,对账对不上。
为了克服这个不足,数据库的隔离级别进一步提出了可重复读的隔离级别。
3、可重复读(REPEATABLE READ)
可重复读的目标是克服读写提交中出现的不可重复读的现象,因为在读写提交的时候,可能一些值的变化,影响当前事务的执行,如上诉的库存就是一个变化的值。接下来,让我们看看可重复读隔离级别是如何解决不可重复读的。
| 时刻 | 事务1 | 事务2 | 备注 |
| T0 | 商品库存初始为1 | ||
| T1 | 读取库存为1 | ||
| T2 | 扣减库存 | 事务未提交 | |
| T3 | 尝试读取库存 | 不允许读取,等待事务1提交 | |
| T4 | 提交事务 | 库存保存为0 | |
| T5 | 读取库存 | 库存为0,无法扣减 |
可以看到,事务2在T3时刻尝试读取库存,但是此时这个库存已经被事务1事先读取,锁住了(相当于加了个写锁,不允许其他事务读和写,实际上是使用行级锁,只锁住该行数据,不允许其他事务读写),所以这个时候数据库就阻塞了事务2的读取,直至事务1提交,事务2才能读取库存的值。此时,完美解决不可重复读问题。
但是这样也会引发新的问题——幻读。
| 时刻 | 事务1 | 事务2 | 备注 |
| T0 | 读取库存50件 | 商品库存初始为100,现在已经销售50件,库存50件 | |
| T1 | 查询交易记录,50笔 | ||
| T2 | 扣减库存 | 事务未提交 | |
| T3 | 插入一笔交易记录 | ||
| T4 | 提交事务 | 库存保存为49件,交易记录为51笔 | |
| T5 | 打印交易记录,51笔 | 这里与查询的不一致,在事务2看来有一笔交易记录是虚幻的 |
这便是幻读现象。幻读:在一个事务内,两次执行同样的范围查询,由于中间被其他事务插入(INSERT)或删除(DELETE)并提交了,导致第二次查询多出(或少掉)几行“凭空出现”的记录。
举例:人事事务A查询“技术部总人数”,得到10人。事务进行中,新员工事务B插入了一条新记录“小王(技术部)”并提交。人事事务A再次查询“技术部总人数”,发现变成了11人。
结果:多了个“幽灵”人员,就像出现了幻觉,且这多出的行无法用锁住“某一行”来阻止。
注意:幻读和不可重复读的区别:
不可重复读是指读到了已经提交的事务的更改数据(修改或删除),幻读是指读到了其他已经提交事务的新增数据。对于这两种问题解决采用不同的办法。为了防止读到更改数据(解决不可重复读),只需对操作的数据添加行级锁,防止操作中的数据发生变化;而为了防止读到新增数据(解决幻读),往往需要添加表级锁,将整张表锁定,防止新增数据(oracle采用多版本数据的方式实现)。
4、串行化(SERIALIZABLE)
串行化是数据库中最高的隔离级别,它要求所有的事务排队顺序执行,即事务只能一个接一个地处理,不能并发。所以它能完全保证数据的一致性。
隔离级别和可能发生的现象总结如下:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 第一类丢失更新 | 第二类丢失更新 |
| 未提交读 | 允许 | 允许 | 允许 | 不允许 | 允许 |
| 读写提交 | 不允许 | 允许 | 允许 | 不允许 | 允许 |
| 可重复读 | 不允许 | 不允许 | 允许 | 不允许 | 不允许 |
| 串行化 | 不允许 | 不允许 | 不允许 | 不允许 | 不允许 |
注意:事务的隔离级别和数据库并发性是成反比的,隔离级别越高,并发性越低。所以应该根据实际情况选择事务的隔离级别。
对于隔离级别,不同的数据库对其的支持也是不一样的。例如,Oracle只能支持读写提交和串行化,而MySQL则能够全部支持。对于Oracle默认的隔离级别为读写提交,MySQL则是可重复读。
5. 总结
读未提交(最低级):不加任何防护。事务没提交的改动,别人也能看到(脏数据满天飞),极不推荐。
读已提交(Oracle默认):只能看到别人提交的数据。解决了脏读,但同一个查询查两次结果可能不同(不可重复读)。
可重复读(MySQL默认):事务开始后,多次查询同一数据始终一致。解决了不可重复读,但查范围数据时可能多出“幽灵行”(幻读)。
串行化(最高级):强制事务挨个排队执行,完全隔离,但并发性能极差,相当于单线程写数据库。
