MySQL数据库运维(1)

四个隔离级别

|------------------|-----|------|-----|
| 隔离级别 | 脏读 | 可重复读 | 幻读 |
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不可能 | 可能 | 可能 |
| REPEATABLE READ | 不可能 | 不可能 | 不可能 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 |

这几周隔离级别就是我们需要进行数据库 M y SQL 运维的优化与检测的关键点了

五大经典问题

1. 脏读(Dirty Read)

定义 :一个事务读取了另一个事务未提交的数据。

通俗理解

  • 事务A修改了一条数据(比如把余额从100改成50),但还没提交
  • 事务B此时读取了这个50
  • 如果事务A最终回滚(Rollback),那么事务B读到的就是"脏"数据------实际上从未真实存在过

例子:

复制代码
时间线:T1          T2          T3          T4

事务A:  开始 → 改余额100→50 → [未提交] → 回滚

事务B:          开始 → 读余额=50 → 继续处理 → 结果错误!

这个简单的来说就是在不同的事务里面,首先在A事务里面将数据改了并且没有提交这个修改,然后在B事务里面查询一下,发现为修改之后的数据。之后在A事务里面回滚(在一个事务中,如果执行失败、出错或主动取消,数据库会把已经做的修改全部撤销,让数据回到事务开始前的状态。)接着在B事务我就去继续查询一下,发现数据是之前修改之前的数据。而在事务B里面我们会认为我们读取到了一个虚假的数据(脏数据)

回滚 = 事务里的"后悔药",一旦触发,数据库就会假装什么都没发生过。

|---------|----------------|------------------|
| 对比项 | Commit(提交) | Rollback(回滚) |
| 作用 | 保存修改 | 撤销修改 |
| 是否永久 | ✅ 是 | ❌ 否 |
| 数据状态 | 对外可见 | 回到事务前 |
| 类似操作 | 保存 | 撤销 |

简单说:读了别人的草稿(未提交),最后人家撕了,就像在这个过程中读取了一个虚假数据一样。

脏读

2. 可重复读(Repeatable Read)------ 注意:这是解决方案,不是问题

定义 :保证在同一个事务中,多次读取同一数据结果一致(不受其他事务影响)。

作用 :解决脏读不可重复读问题。

对比说明

  • 不可重复读(这是问题):事务A两次读取同一行,事务B在中间修改了这行并提交,导致A两次结果不同
  • 可重复读:通过锁机制或多版本控制,让A在两次读取期间,看到B的修改被"屏蔽",保证结果一致

可重复读:确保你读的是"快照",别人改了你也看不到

可重复读:在拍照片时候拍射了两张照片,明明是连续拍的,并且要求都保持姿势了,为什么不同,因为有人在拍下一张时候动了一下子,改了原来的动作,所以不一样了

我们来看看在可重复读下的幻读

sql 复制代码
-- 事务A

BEGIN;

SELECT COUNT(*) FROM orders WHERE user_id = 1;  -- 10条



-- 事务B插入一条

INSERT INTO orders (user_id, ...) VALUES (1, ...);



-- 事务A再查

SELECT COUNT(*) FROM orders WHERE user_id = 1;  -- 还是10条(不可重复读)

-- 但如果插入

INSERT INTO orders (user_id, ...) VALUES (1, ...);  -- 报主键冲突!

我们来剖析一下过程:

刚开始在事务A查询得到10条数据,此时生成一个 一致性读视图(Read View),之后所有普通SELECT都基于这个快照,不会看到事务 B 的新数据。接下来在事务B插入了一条数据并且提交到了数据库。而之后事务A再次查询时候发现是10条,这是因为使用的是快照,不会看到事务 B 的提交。好的,之后我们尝试在A里面插入一条数据,哎,发现不行,它会报主键冲突。

这个核心原因在于:SELECT看不到 ≠ 数据库里不存在

我们看看AI的解释

也就是说,在 REPEATABLE READ下,你可以用 SELECT"假装"世界没变,但数据库会在你写数据时告诉你真相。我靠了,好家伙,用之前一直以为这只是因为设置了它的隔离级别所以才会事务B插入的数据对于事务A没有影响,但是没有想到会是删改查里面的它们的之后操作前面还有一个扫描读取的过程,哎,这么一下来,好像删的过程中也是会先扫描一下是否有这个数据。

方案一(最推荐):先锁后查

BEGIN;

SELECT COUNT(*)

FROM orders

WHERE user_id = 1

FOR UPDATE; -- 加行锁 / 间隙锁

  • 阻止事务 B 插入
  • 保证可重复读 + 写入安全

方案二:直接 INSERT,捕获异常

INSERT INTO orders (...)

ON DUPLICATE KEY UPDATE ...

  • 依赖唯一索引兜底
  • 业务层处理冲突

方案三:降低隔离级别(不推荐)

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

  • 能看到别人插入的数据
  • 但更容易出现并发问题

因为在 REPEATABLE READ隔离级别下,SELECT使用 MVCC 快照读,看不到其他事务提交的数据;但 INSERT会进行当前读并检查唯一索引,因此即使 SELECT 没查到,也会发生主键冲突。

3. 幻读(Phantom Read) 【读提交的问题】

定义 :一个事务内多次查询返回的记录行数不同,仿佛出现了"幻觉"。

产生原因

  • 事务A查询某条件下的记录(如"所有年龄>30岁的用户"),得到10条
  • 事务B插入了一条新的年龄>30岁的记录并提交
  • 事务A再次查询相同条件,得到11条------新增的行就像"幻觉"一样出现了

|--------|----------|----------|
| 类型 | 针对对象 | 变化方式 |
| 不可重复读 | 同一行数据 | 修改、删除 |
| 幻读 | 数据行集合 | 新增、删除 |

sql 复制代码
-- 事务A

BEGIN;

SELECT COUNT(*) FROM orders WHERE user_id = 1;  -- 10条



-- 事务B插入一条并提交

INSERT INTO orders (user_id, ...) VALUES (1, ...);

COMMIT;



-- 事务A再查

SELECT COUNT(*) FROM orders WHERE user_id = 1;  -- 11条(能读到新插入的)

你数苹果数了3次,每次数量都不同,因为有人偷偷放/拿苹果

快照读:普通SELECT语句,读取的是快照数据

当前读:SELECT ... FOR UPDATE、INSERT、UPDATE、DELETE,读取的是最新数据

sql 复制代码
-- 快照读(不受事务影响)

SELECT * FROM orders;



-- 当前读(会加锁)

SELECT * FROM orders FOR UPDATE;

这就是我们在事务A里面修改时候会出现报错的原因。

4.丢失更新(Lost Update)【库存扣减】

当两个事务同时读取同一数据,并各自基于该数据进行修改,后提交的事务会覆盖先提交事务的修改,导致较早的更新"丢失"。

发生场景(典型例子)

假设账户余额初始为 1000元:

|--------|---------------------|----------------------|
| 时间 | 事务 A(转账转出) | 事务 B(转账转入) |
| T1 | 读取余额 = 1000 | |
| T2 | | 读取余额 = 1000 |
| T3 | 计算 1000 - 200 = 800 | |
| T4 | | 计算 1000 + 300 = 1300 |
| T5 | 写入 800(提交) | |
| T6 | | 写入 1300(提交) |
| T7 | | 最终余额 = 1300​ |

问题 :事务A的扣款操作(减200)被事务B完全覆盖了!正确结果应该是 1100 (1000-200+300),但实际变成了1300,A的更新丢失了

解决方式

1.悲观锁(Pessimistic):读取时加排他锁(X锁/写锁),直到事务结束才释放

2.乐观锁(Optimistic):使用版本号(Version)或时间戳,提交时检查数据是否被他人修改过

3.隔离级别:至少使用 Read Committed​ 或更高,我们学习过隔离级别的都知道越高,它的这些问题会相对的减少,当然它的执行效能也会相对的缓慢

方案一:使用数据库的乐观锁(推荐,最常用)

在 product表中增加一个版本号字段(version)或者直接使用库存字段作为校验条件。

修改后的 SQL 逻辑:

sql 复制代码
-- 请求A执行
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 0; -- 假设初始 version=0

-- 请求B执行
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 0; -- 此时 version 已被 A 改为 1,B 的更新影响行数为 0,更新失败

原理:通过 WHERE条件判断数据是否被其他人动过。如果动了(版本号变了),就更新失败,应用程序捕获这个失败并重试或报错。

方案二:使用悲观锁(FOR UPDATE)

在读取数据时就把这行数据"锁住",不让别人读,直到你改完提交事务。

代码示例(伪代码):

sql 复制代码
BEGIN;
-- 开启事务后,立即锁定这一行
SELECT stock FROM product WHERE id = 1 FOR UPDATE;

-- 此时其他请求执行到这里会被阻塞,必须等 A 提交事务才能继续
UPDATE product SET stock = stock - 1 WHERE id = 1;

COMMIT; -- 提交事务,释放锁

原理:我先把数据占了,你们排队等着,等我改完了你们才能看新的数据。

方案三:直接利用 UPDATE 语句的特性(最简单)

很多数据库在执行 UPDATE ... SET stock = stock - 1时,本身是具备原子性的(内部有行级锁),不需要先 SELECT。

做法:

去掉最初的 SELECT,直接执行 UPDATE。

UPDATE product SET stock = stock - 1 WHERE id = 1;

一样的,事务B没有意识到更操作数据

5. 脏写(Dirty Write)

定义

一个事务修改了另一个尚未提交的事务已经修改过的数据。如果后一个事务回滚(Rollback),而前一个事务提交(Commit),就会导致数据处于不一致状态。

发生场景(典型例子)

假设库存初始为 100件

|--------|-------------------|-------------------------------------|
| 时间 | 事务 A(退货入库) | 事务 B(销售出库) |
| T1 | 读取库存 = 100 | |
| T2 | 写入 100 + 50 = 150 | |
| T3 | | 读取库存 = 150 |
| T4 | | 写入 150 - 30 = 120 |
| T5 | **回滚(Rollback)**​ | |
| T6 | 库存恢复为 100 | |
| T7 | | **提交(Commit)**​ |
| T8 | | 最终库存 = 120(但实际上货品并未真的出库,因为A回滚了) |

问题:事务B基于事务A未提交的"脏数据"(150)进行了修改,但A最终回滚了,导致B的

提交建立在错误数据上。

与"丢失更新"的区别

脏写:涉及未提交数据的覆盖,更底层、更严重(可能破坏数据一致性)

丢失更新:通常发生在两个都已提交的事务之间(或至少一个提交),是更新被覆盖

解决方式

通过排他锁(X锁)机制解决:事务修改数据时加X锁,直到事务结束(提交或回滚)才释

放,其他事务不能读取或修改该行

所有支持事务的数据库(如MySQL InnoDB、PostgreSQL、Oracle)默认都会防止脏写

对应隔离级别:Read Uncommitted 及以上​ 都不会出现脏写(实际上现代数据库在 Read Uncommitted 也会阻止脏写,具体取决于实现)

|-----------|------------------------|----------------------|
| 特性 | 丢失更新 (Lost Update) | 脏写 (Dirty Write) |
| 触发条件​ | 后提交覆盖先提交 | 未提交数据被覆盖 |
| 数据状态​ | 都基于旧版本数据修改 | 基于未提交(脏)数据修改 |
| 严重程度​ | 数据逻辑错误 | 数据一致性严重破坏 |
| 是否允许​ | Read Committed 可能允许 | 几乎所有数据库都不允许 |
| 解决方案​ | 锁、版本控制、MVCC | 排他锁(X锁) |

对于以上所有的我们其实并不是它的隔离级别越高越好,我们需要根据实际的情况而决定

我们其实可以将读和写差分理解:MVCC 就是让数据库同时保存数据的"过去"和"现在",读操作看"老照片",写操作拍"新照片"。简单的理解为我们对于保存记录的一个容器(可以是照片也可以是其他的存储物)的理解。

对于快照的理解(一致性快照实现)

  1. 每行数据后面保存两个隐藏列:创建版本号和删除版本号

这是 MVCC 的物理基础。除了我们定义的 id、name、stock等业务字段外,InnoDB 在每一行记录后面,还会偷偷藏两个字段(在源码层面):

DB_TRX_ID(创建/修改版本号):记录这行数据是由哪个事务创建或修改的。

DB_ROLL_PTR(删除/回滚指针):指向这行数据上一个版本的地址(像时光机的指针)。

简单的理解:

这就好比给每个人(每行数据)发了一张"出生证明"(谁生的我)和一张"死亡证明"(谁删的我)。当需要去找回那些删除的历史数据时,可以通过这些证明在"回滚日志"里把旧版本找出来。

  1. 读操作读取事务开始时的快照

这就是所谓的 "快照读"(Snapshot Read)。

当一个事务(比如你的查询事务)开始时,InnoDB 会给它拍一张"快照"(生成一个 Read View)。

在这个事务后续的查询中,它看到的永远是这张快照时刻数据库的样子。

哪怕其他事务把数据改了、提交了,当前事务也看不见。这个其实就是我们前面的可重复读的幻读,只有在我们插入或者修改时候会出现。

简单的理解:

我们坐在时光机里看电影,设定好时间点是 10:00。不管电影后面怎么演(其他事务怎么改),我们在机器里看到的永远是第 10 分钟那一帧的画面。

  1. 写操作创建新版本,标记旧版本为删除

这是 "当前读"(Current Read)​ 的过程。

当事务要修改或删除数据时,并不会直接覆盖旧数据。

而是保留旧数据(写入 Undo Log),然后在原位置插入一条全新的记录,并把新记录的版本号改成当前事务的 ID。

旧版本的数据依然存在,只是被标记为"无效"或"已删除"。

简单的理解:

你有一份文档(旧版本)。你想修改一句话,你不会直接在原件上涂改,而是复印一份(新版本),在复印件上修改,然后把原件放进碎纸机旁边(标记为删除)。以后有人想看原件(这个就方便我们找回最开始记录的数据),还能找出来。

  1. 这样读写操作互不阻塞,提高并发性能

这是 MVCC 的终极目的(非阻塞读)。

传统数据库:​ 如果 A 在写,B 就不能读;如果 C 在读,D 就不能写。必须排队(无法做到多版本并发控制)。

有了 MVCC:

写操作:​ 只管去写新版本,不用管别人读什么。

读操作:​ 只看旧版本,不用等别人写完。

MVCC 通过空间换时间(多存了几个版本的数据)和时间戳控制(版本号比对),让读写操作像两条平行的铁轨,互不干扰,从而极大地提高了数据库的并发处理能力。

当然,还有一个非常的重要的问题就是死锁问题

两个事务互相等对方释放锁

报错实例:Deadlock found when trying to get lock

|--------|--------------|--------------|
| 时间 | 事务 T1 | 事务 T2 |
| t1 | UPDATE A | |
| t2 | | UPDATE B |
| t3 | UPDATE B(等待) | |
| t4 | | UPDATE A(等待) |
| t5 | ❌ 死锁 | ❌ 死锁 |

主要是:加锁顺序不一致/事务过大/锁范围不可控

解决方法:

1.统一加锁顺序,永远按同一顺序访问资源,之后重试

2.缩小事务范围,重试

3.捕获死锁并重试

相关推荐
Yang96111 小时前
宽频高精度!鼎讯信通 OM-T 台式频谱分析仪风电实验室专用
大数据·运维·网络
柏舟飞流1 小时前
StarRocks: 新一代极速全场景MPP数据库
数据库
Database_Cool_1 小时前
阿里云 AnalyticDB MySQL 免运维实践:分析型数据库不需要专人运维
数据库·数据仓库·mysql·阿里云
小镇敲码人1 小时前
MySQL事务介绍
android·数据库·mysql·adb
AIMath~1 小时前
MongoDB数据库,MySQL数据库,Redis数据库,Milvus数据库对比分析与和核心总结
数据库·mysql·mongodb·milvus
憧憬成为java架构高手的小白1 小时前
mysql(ai总结每章的知识)
数据库·mysql·oracle
彭祥.1 小时前
基于SQLite与face_recognition的人脸库管理
数据库·计算机视觉·sqlite
AugustRed1 小时前
Docker原理和使用指南、常用命令、Compose多容器部署
运维·docker·容器
一只fish1 小时前
Oracle官方文档翻译《Database Concepts 26ai》第19章-应用与网络服务架构
数据库·oracle