当前位置: 首页 > article >正文

Mysql 默认隔离级别分布式锁乐观锁

Mysql 中 的 update, delete, insert 操作默认 加行锁(悲观锁),为什么还有超卖的情况?

  1. 查询和更新的非原子性
// 这是两个独立的操作
Stock stock = this.stockMapper.selectOne(...);  // 查询操作
if (stock != null && stock.getCount() > 0) {    
    stock.setCount(stock.getCount() - 1);       
    this.stockMapper.updateById(stock);         // 更新操作
}
  • 查询(select)操作默认不会加锁,在查询和更新之间可能有其他事务修改了数据
  • 这就造成了读-写分离,无法保证原子性

MySQL 各个隔离级别解决的问题

• √ 表示可能发生

• X 表示不会发生

隔离级别脏读不可重复读幻读
读未提交
读已提交X
可重复读XX
串行化XXX

解决脏读需升级到「读提交」以上隔离级别;

解决不可重复读要升级到「可重复读」级别;

不建议为解决幻读将隔离级别升至「串行化」。

例子

读未提交

下面模拟两个事务来演示读未提交(Read Uncommitted)可能导致的脏读问题:

假设有一个账户表 account:

CREATE TABLE account (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    balance DECIMAL(10,2)
);

INSERT INTO account VALUES (1, '张三', 1000.00);

事务1和事务2的执行过程:

事务1:

-- 设置隔离级别为读未提交
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

BEGIN;  -- 开启事务1

-- T1时刻: 查询张三的余额
SELECT balance FROM account WHERE id = 1;
-- 结果显示: 1000.00

-- T3时刻: 再次查询张三的余额(这时会读取到事务2未提交的数据)
SELECT balance FROM account WHERE id = 1;
-- 结果显示: 2000.00 (脏读)

-- T5时刻: 再次查询张三的余额(事务2回滚后)
SELECT balance FROM account WHERE id = 1;
-- 结果显示: 1000.00

COMMIT;

事务2:

BEGIN;  -- 开启事务2

-- T2时刻: 更新张三的余额
UPDATE account SET balance = 2000.00 WHERE id = 1;

-- T4时刻: 发现操作有误,决定回滚
ROLLBACK;

执行时间顺序:

  1. T1: 事务1查询余额 = 1000
  2. T2: 事务2修改余额为2000(未提交)
  3. T3: 事务1再次查询,读取到了事务2未提交的数据 = 2000(脏读)
  4. T4: 事务2回滚
  5. T5: 事务1再次查询 = 1000

这个例子展示了读未提交隔离级别下的脏读问题:

  • 事务1在T3时刻读取到了事务2未提交的数据(balance = 2000)
  • 而事务2最终回滚了,说明这个2000是一个无效的数据
  • 事务1读取到了一个最终并不存在的数据,这就是脏读

这种情况在实际应用中是非常危险的,可能会导致业务逻辑错误。因此在实际生产环境中,通常至少使用READ COMMITTED隔离级别,而不是READ UNCOMMITTED。

读已提交

CREATE TABLE account (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    balance DECIMAL(10,2)
);

INSERT INTO account VALUES (1, '张三', 1000.00);

事务1和事务2的执行过程:

事务1:

-- 设置隔离级别为读已提交
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

BEGIN;  -- 开启事务1

-- T1时刻: 第一次查询张三的余额
SELECT balance FROM account WHERE id = 1;
-- 结果显示: 1000.00

-- T3时刻: 再次查询张三的余额(这时不会读取到事务2未提交的数据)
SELECT balance FROM account WHERE id = 1;
-- 结果显示: 1000.00 (避免了脏读)

-- T5时刻: 再次查询张三的余额(事务2已提交)
SELECT balance FROM account WHERE id = 1;
-- 结果显示: 2000.00 (不可重复读)

COMMIT;

事务2:

BEGIN;  -- 开启事务2

-- T2时刻: 更新张三的余额
UPDATE account SET balance = 2000.00 WHERE id = 1;

-- T4时刻: 提交事务
COMMIT;

执行时间顺序:

  1. T1: 事务1查询余额 = 1000
  2. T2: 事务2修改余额为2000(未提交)
  3. T3: 事务1再次查询 = 1000(因为事务2还未提交,所以看不到修改后的数据)
  4. T4: 事务2提交
  5. T5: 事务1再次查询 = 2000(因为事务2已提交,所以看到了新数据)

这个例子展示了:

  1. 读已提交解决了脏读问题:
    • 在T3时刻,事务1无法读取到事务2未提交的数据
    • 这避免了读取到可能会被回滚的"脏"数据
  2. 但读已提交仍然存在不可重复读的问题:
    • 事务1在T1和T5时刻的两次读取得到了不同的结果
    • 这是因为在这期间,事务2提交了新的数据
    • 这种现象就是"不可重复读"

对比读未提交:

  • 读未提交级别下,事务1在T3时刻就能看到事务2未提交的修改
  • 读已提交级别下,事务1必须等到事务2提交后才能看到修改

在实际应用中:

  • 读已提交是很多数据库的默认隔离级别(如PostgreSQL)
  • 它提供了基本的数据一致性保证,避免了脏读
  • 对于大多数应用来说,不可重复读的问题是可以接受的
  • 如果业务需要避免不可重复读,则需要使用更高的隔离级别(如可重复读)

可重复读

下面演示可重复读(Repeatable Read)隔离级别,展示它如何避免不可重复读,但可能出现幻读:

使用相同的账户表,但增加一条数据:

CREATE TABLE account (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    balance DECIMAL(10,2)
);

INSERT INTO account VALUES (1, '张三', 1000.00);
INSERT INTO account VALUES (2, '李四', 1000.00);

事务1和事务2的执行过程:

事务1:

-- 设置隔离级别为可重复读
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

BEGIN;  -- 开启事务1

-- T1时刻: 查询余额大于500的账户数量
SELECT COUNT(*) FROM account WHERE balance > 500;
-- 结果显示: 2条记录

-- T3时刻: 再次查询余额大于500的账户数量
SELECT COUNT(*) FROM account WHERE balance > 500;
-- 结果仍然显示: 2条记录(可重复读,不会看到事务2的修改)

-- T5时刻: 查询所有账户信息
SELECT * FROM account WHERE balance > 500;
-- 仍然只能看到原来的2条记录

-- T6时刻: 尝试更新所有余额大于500的账户
UPDATE account SET balance = balance + 100 WHERE balance > 500;
-- 这时会发现实际影响了3条记录(幻读)

COMMIT;

事务2:

BEGIN;  -- 开启事务2

-- T2时刻: 插入新账户
INSERT INTO account VALUES (3, '王五', 2000.00);

-- T4时刻: 提交事务
COMMIT;

执行时间顺序:

  1. T1: 事务1查询余额>500的账户数量 = 2
  2. T2: 事务2插入新账户(王五,2000元)
  3. T3: 事务1再次查询数量 = 2(看不到新插入的记录)
  4. T4: 事务2提交
  5. T5: 事务1查询仍然看到2条记录
  6. T6: 事务1更新操作实际影响了3条记录(包括了王五的账户)

这个例子展示了:

  1. 可重复读解决了不可重复读问题:
    • 在整个事务1执行期间,查询结果都是一致的
    • 即使事务2提交了新数据,事务1的查询结果仍然不变
    • 这保证了同一事务中多次读取得到的结果是一致的
  2. 但可重复读仍然存在幻读问题:
    • 虽然查询操作看不到新插入的数据
    • 但在执行UPDATE操作时,会影响到事务期间其他事务插入的新数据
    • 这种现象就是"幻读"

可重复读的特点:

  1. 提供了事务隔离的快照读(Snapshot Read)
    • 事务开始时会创建一个数据快照
    • 后续的查询都基于这个快照
    • 保证了查询结果的一致性
  2. 但对于写操作(INSERT/UPDATE/DELETE):
    • 会检查最新的数据状态
    • 可能会发现"幻影"记录

:::success
总结:

这是可重复读隔离级别的核心特性在起作用。在可重复读隔离级别下,当一个事务开始时,它会获得当前数据库的一个一致性视图。此后,无论其他事务做了什么更改(包括插入、更新、删除),事务1在它未提交之前始终看到的都是该一致性视图内的内容

这正是可重复读隔离级别设计的目的:在同一个事务中,读操作的结果始终是事务开始时的快照数据,不受其他事务并发修改的影响

:::

串行化

下面演示最高的隔离级别:串行化(Serializable),它通过强制事务串行执行来避免所有并发问题:

使用相同的账户表:

CREATE TABLE account (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    balance DECIMAL(10,2)
);

INSERT INTO account VALUES (1, '张三', 1000.00);
INSERT INTO account VALUES (2, '李四', 1000.00);

事务1和事务2的执行过程:

事务1:

-- 设置隔离级别为串行化
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

BEGIN;  -- 开启事务1

-- T1时刻: 查询余额大于500的账户
SELECT * FROM account WHERE balance > 500;
-- 结果显示2条记录

-- T2时刻: 等待一段时间,让事务2尝试插入数据
-- 此时事务1会持有共享锁,事务2的插入操作会被阻塞

-- T3时刻: 再次查询
SELECT * FROM account WHERE balance > 500;
-- 仍然是2条记录

-- T4时刻: 更新操作
UPDATE account SET balance = balance + 100 WHERE balance > 500;
-- 更新2条记录

COMMIT;  -- 提交后,事务2才能继续执行

事务2:

BEGIN;  -- 开启事务2

-- T2时刻: 尝试插入新账户
INSERT INTO account VALUES (3, '王五', 2000.00);
-- 这个操作会被阻塞,直到事务1提交

-- 事务1提交后,该插入操作才能执行

COMMIT;

执行时间顺序和特点:

  1. 事务1开始执行并查询数据
    • 获取了表的共享锁
  2. 事务2尝试插入数据
    • 由于事务1持有共享锁,事务2会被阻塞
    • 必须等待事务1完成并释放锁
  3. 事务1继续执行并最终提交
    • 释放所有锁
  4. 事务2获得锁并继续执行
    • 完成插入操作
    • 提交事务

串行化的特点:

  1. 完全串行执行
-- 在事务1执行期间,事务2的以下操作都会被阻塞:
INSERT INTO account VALUES (3, '王五', 2000.00);
UPDATE account SET balance = 3000 WHERE id = 1;
DELETE FROM account WHERE id = 2;
  1. 读写互斥
-- 事务1在查询时
SELECT * FROM account WHERE balance > 500;
-- 会阻止其他事务的写操作
  1. 写写互斥
-- 如果事务1正在更新数据
UPDATE account SET balance = balance + 100 WHERE id = 1;
-- 事务2的更新操作会被阻塞
UPDATE account SET balance = balance - 100 WHERE id = 1;

可能出现的错误:

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
  • 当一个事务等待锁的时间超过超时设置时会报此错误

串行化的优缺点:

优点:

  1. 提供最高级别的隔离
  2. 完全避免脏读、不可重复读和幻读
  3. 确保数据的完全一致性

缺点:

  1. 性能最差
  2. 并发度最低
  3. 容易发生锁超时
  4. 可能导致应用程序响应变慢

使用建议:

  1. 只在严格要求数据一致性的场景使用
  2. 需要考虑并发量和性能要求
  3. 建议在业务低峰期执行
  4. 可能需要调整锁超时参数
-- 查看当前锁超时设置
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

-- 修改锁超时时间(单位:秒)
SET innodb_lock_wait_timeout = 50;

在实际应用中:

  • 串行化很少使用,因为性能代价太大
  • 大多数应用使用REPEATABLE READ就足够了
  • 如果需要更高的隔离级别,通常通过应用层的锁来实现,而不是使用数据库的串行化隔离级别

拓展

:::info

区别:

update db_stock set count = count - 1 where product_code = '1001' and count >= 1

不属于 CAS 。

  • CAS: 必须库存等于指定值才能更新
  • CAS操作示例:

update db_stock

set count = count - 1

where product_code = ‘1001’

and count = 3; – 这里指定了具体的预期值3

:::

update db_stock set count = count - 1 where product_code = '1001' and count >= 1

上述更新时判断 会造成 的问题:

  1. 锁范围问题: 行锁 表级锁
  2. 同一个商品有多条库存记录
  3. 无法记录库存变化前后的数量

锁范围

当修改数据时,触发的是表锁还是行锁呢?

情景:

Mysql库存表

对Mysql库存表的前两个数据做了修改

  • 这也是设置三条数据的原因。接下来探究修改第三条数据是否成功。如下图

修改第三条数据

  • 很明显这个事务陷入了阻塞状态。说明修改数据触发的表锁。

事务1执行rollback

  • 执行了 rollback 或者 commit 使得事务终止。立刻观察事务 2 的 状态。

事务2 立即得到执行

显然在高并发的情况,表锁会大大降低并发效率。下面介绍如何使用行锁:

将product_code 设置为索引

在事务1 中 修改1001 号 数据,触发表锁

  • mysql悲观锁中使用行级锁:
  1. 锁的查询或者更新条件必须是索引字段
  2. 查询或者更新条件必须是具体值

在事务2 中,执行修改1002数据。没有发生阻塞直接成功

继续探究: 查询或者更新条件必须是具体值

索引字段!=‘1002’不是具体值

  • 查询或者更新条件必须是具体值,触发的是表级锁

事务2 执行被阻塞

乐观锁

CAS (Compare And Swap,比较并交换) 是一种乐观锁的实现方式。类似于在网站上修改密码的操作。

  1. CAS的基本原理:
CAS(V, A, B)
- V:要更新的变量
- A:预期的原值
- B:新值
当且仅当 V 的值等于 A 时,才将 V 的值更新为 B
  1. 在MySQL中的CAS示例:
-- 库存更新的CAS操作
UPDATE stock 
SET count = count - 1 
WHERE id = 1 AND count = 3;  -- 只有当count确实为3时才更新
  1. Java中的CAS示例:
// AtomicInteger就是基于CAS实现的
AtomicInteger count = new AtomicInteger(3);
boolean success = count.compareAndSet(3, 2);  // 如果当前值是3,则更新为2
  1. CAS的优点:
  • 不需要加锁,性能好
  • 避免了死锁问题
  • 适合读多写少的场景
  1. CAS的缺点:
  • ABA问题(值从A改为B又改回A)
  • 循环时间长开销大
  • 只能保证一个共享变量的原子操作
  1. 实际应用场景:
// 库存扣减的CAS实现
public boolean deductStock(Long id, Integer expectCount) {
    return stockMapper.updateStock(id, expectCount) > 0;
}

// Mapper方法
@Update("UPDATE stock SET count = count - 1 WHERE id = #{id} AND count = #{expectCount}")
int updateStock(@Param("id") Long id, @Param("expectCount") Integer expectCount);

乐观锁乐观锁尝试1

上述乐观锁代码存在下面问题:

  • 递归调用造成 stackoverflow
  • 事务连接超时

正确的乐观锁代码

  • 去除了@Transactional注解,乐观锁本身就是通过版本号控制并发,整个操作只有一个原子性的UPDATE语句,不需要事务来保证操作的原子性。事务会持有数据库连接更长时间,在重试机制下,长事务可能导致数据库连接资源耗尽,可能造成不必要的数据库锁等待
  • 添加适当的重试间隔,避免立即重试。

总结

  1. jvm本地锁:三种情况导致锁失效 吞吐量: 600

多例模式

事务:Read Uncommitted

集群部署

  1. 一个sql语句:更新数量时判断 吞吐量:2000

解决:三个锁失效

问题:

1. 锁范围问题 表级锁 行级锁

2. 同一个商品有多条库存记录

3. 无法记录库存变化前后的状态
  1. 悲观锁:select … for update

解决了 2 中 的 2,3 两个情况 吞吐量: 600> JVM 本地锁

问题:

1. 性能问题

2. 死锁问题:对多条数据加锁时,加锁顺序要一致

3. 库存操作要统一:select ... for update 普通select:

SELECT … FOR UPDATE加的是排他锁(X锁),它只会阻塞其他事务的写操作加锁的读操作(SELECT … FOR UPDATE),而不会阻塞普通的SELECT读操作。

@Transcational
void deduct() {
    // T1读取库存=10
    Stock stock = stockDao.selectById(1);
    
    // T2同时读取库存=10
    
    // T1更新库存=9
    stock.setCount(stock.getCount() - 1);
    stockDao.updateById(stock);
    
    // T2也更新库存=9
    // 实际应该是8
}

@Transcational 注解:主要解决的是单个事务内的原子性问题。

  • 对于并发问题,需要额外的锁机制来解决
  • 就像一个人同时做多件事情:
    • 原子性保证这个人做的多件事要么都完成,要么都不完成
    • 但无法阻止其他人同时做同样的事情(并发问题)

Question

Q1:

如果这个函数中,事务执行的操作只有 1 个,那还需要加注解吗?

不完全正确。即使只有一个操作,有些情况下还是需要 @Transactional 注解的。


  1. 单个操作不需要 <font style="color:rgb(68, 64, 60);">@Transactional</font> 的情况
void updateName(Long id, String newName) {
    
    // 单纯的更新操作,数据库本身保证原子性
    
    userDao.updateName(id, newName);
}
  1. 单个操作需要 <font style="color:rgb(68, 64, 60);">@Transactional</font> 的情况:

场景一:涉及多表关联更新

@Transactional
void saveUser(User user) {
    // 虽然是一个save方法,但底层可能涉及多表操作
    // 比如user表和user_info表都需要插入数据
    userDao.save(user);
}

场景二:需要事务的传播特性

@Transactional(propagation = Propagation.REQUIRED)
void methodA() {
    // 单个操作,但需要和调用者共享事务
    userDao.update();
}
  • 对上述代码补充下面情景
@Service
public class UserService {
    @Transactional
    public void methodA() {
        // 更新用户信息
        userDao.update();
        // 调用methodB
        methodB();
    }

    @Transactional(propagation = Propagation.REQUIRED)
    public void methodB() {
        // methodB的操作会在methodA的事务中执行
        userDao.updateStatus();
    }
}

场景三:需要特定的隔离级别

@Transactional(isolation = Isolation.SERIALIZABLE)
void sensitiveOperation() {
    // 单个操作,但需要特定的隔离级别
    accountDao.update();
}

场景四:需要事务回滚规则

@Transactional(rollbackFor = BusinessException.class)
void businessOperation() {
    // 指定特定异常回滚
    userDao.update();
}

http://www.kler.cn/a/519609.html

相关文章:

  • JAVAweb学习日记(八) 请数据库模型MySQL
  • ray.rllib-入门实践-11: 自定义模型/网络
  • 第22章 走进xUnit:测试驱动开发的关键工具(持续探索)
  • 凝“华”聚智,“清”创未来-----华清远见教育科技集团成都中心2024年度总结大会暨2025新春盛典
  • 【论文阅读】HumanPlus: Humanoid Shadowing and Imitation from Humans
  • 蓝桥杯之c++入门(一)【第一个c++程序】
  • 27. 【.NET 8 实战--孢子记账--从单体到微服务】--简易报表--报表服务
  • Docker 系列之 docker-compose 容器编排详解
  • 【信息系统项目管理师-选择真题】2017上半年综合知识答案和详解
  • Transfoemr的解码器(Decoder)与分词技术
  • QT:控件属性及常用控件(4)-----多元素控件、容器类控件、布局管理器
  • 3.numpy练习(2)
  • RabbitMQ 分布式高可用
  • 【Linux】Linux编译器-g++、gcc、动静态库
  • 7、知识库内容更新与自动化
  • 系统编程(线程互斥)
  • 牛角棋项目实践1:牛角棋的定义和用python实现简单功能
  • 大模型开发 | RAG在实际开发中可能遇到的坑
  • rewrite规则
  • STL中的list容器