数据库 并发更新之乐观锁和悲观锁

1. 问题引出

假设现在有一张 item 商品表,quantity 字段表示该商品的数量。

这时候有一个用户下了订单,购买一件商品。那么我们可以用以下 SQL 来实现这个逻辑

UPDATE item SET quantity = quantity - 1 WHERE id = 1;

这个实现在一般情况下是没有问题的,但是现在的后端应用都是在多线程或者多进程环境下运行,在高并发情况下就有可能发生问题

假设现在有 A 和 B 两个用户同时下单,后端服务会分配 2 个不同的线程去处理请求,这里分别用线程 A 和 B 来表示。

线程A(用户A下单)线程B(用户B下单)
查询商品 id = 1,此时 quantity = 100
查询商品 id = 1,此时 quantity = 100
用户A下单,更新 quantity = 99
用户B下单,更新 quantity = 99

在线程 A 还没更新数量之前,B 就去把商品数量查出来了,并发更新导致数据不一致,业务上就体现为超卖。

那么这个问题该如何解决呢?答案就是加锁。锁可以在不同的层面加。如果是单实例应用,直接加本地锁,例如 Java 应用可以使用 synchronized。如果是分布式应用,可以通过 Redis、ZooKeeper、Etcd 加分布式锁

这种情况是数据库并发更新导致的,能不能直接在数据库层面解决呢?答案是可以的,可以利用数据库锁机制来解决并发更新问题。方案有悲观锁和乐观锁,本文对这2种解决方案展开说明

2. 数据库悲观锁解决并发更新

MySQL 的 InnoDB 引擎提供了以下两种行锁机制。在查询记录时,使用以下 SQL,可以给对应行加上共享锁和排他锁。

-- 共享锁(S)
SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE
-- 排他锁(X)
SELECT * FROM table_name WHERE ... FOR UPDATE

其中 SELECT ... FOR UPDATE 是悲观锁的具体实现。并发更新可以通过该机制保证数据一致性

需要注意的是,锁在 autocommit=0 状态下使用才有意义,因为锁会在 commit 之后自动释放。默认情况下 MySQL 单行语句就是一个事务,加锁语句执行完,锁立即就被释放了,也就没意义了

下面给出 SELECT ... FOR UPDATE 解决并发更新的示例

-- A和B开启事务
BEGIN;
-- A查询,加上排他锁
SELECT * FROM item WHERE id = 1 FOR UPDATE;
    -- B查询加锁,由于锁被A占用,所以阻塞
    SELECT * FROM item WHERE id = 1 FOR UPDATE;
-- A更新
UPDATE item SET quantity = quantity - 1 WHERE id = 1;
-- A提交
COMMIT;
    -- B成功查询出记录,继续执行更新
    UPDATE item SET quantity = quantity - 1 WHERE id = 1;
    -- B提交
    COMMIT;

3. 数据库乐观锁解决并发更新

乐观锁本质上不加锁,是一种 CAS 无锁机制。所谓 CAS,就是在更新的时候,检查该实际值是不是和期望值一样,一样就更新成功,不一样就更新失败

下面给出 CAS 解决并发更新的示例

-- A 查出来 quantity = 100
SELECT * FROM item WHERE id = 1;
    -- B 查出来 quantity = 100
    SELECT * FROM item WHERE id = 1;
-- A 更新 quantity,同时加上 where 条件检查 quantity 是不是期望值。发现是,更新成功
UPDATE item SET quantity = quantity - 1 WHERE id = 1 AND quantity = 100;
    -- B 更新 quantity,发现 quantity 不是期望值,更新失败
    UPDATE item SET quantity = quantity - 1 WHERE id = 1 AND quantity = 100;

CAS 存在更新失败的情况。如何判断更新是否失败呢?这也很简单,UPDATE 语句返回值代表更新的行数,直接判断返回值是不是 0 即可,0 就是失败。

现在我们可以判断更新失败了,那如何解决呢?这个得具体业务具体解决了。如果业务容许这种错误发现,可以给用户一个错误提示,比如:

// 查询记录
doQuery();

// CAS 更新
if (doCasUpdate() == 0) {
    doError("提示系统繁忙,请重试");
}

如果业务不容许失败,这时候可以加一个死循环进行重试

while (true) {
    // 查询记录
    doQuery();

    // CAS 更新
    if (doCasUpdate() > 0) {
        break;
    }
}

4. 乐观锁 CAS 的 ABA 问题

我们继续以商品这个场景举例,假设现在有3个操作同时进行,分别是 A、B 用户同时下单,C 用户添加商品数据

线程A(用户A下单)线程B(用户B下单)线程C(运营C添加库存)
查询 quantity = 100查询 quantity = 100
更新 quantity = 99
更新 quantity = 99+1= 100
更新 quantity = 99

用户 B 下单减库存本来应该失败的,但是在 C 用户的干预下,更新商品数量成功了,因为 quantity 在中间阶段又被更新回预期值 100

这就是 ABA 问题。一个变量一开始是A,被修改为B,又被修改为A,这在程序看来数据是没有变化的。但实际上此A非彼A。

这个情况对业务有没有影响呢?在这个商品数量场景下确实是没有影响的。但是有的业务可能是会有影响的。这时候需要单独引入一个版本号或时间戳字段来解决

SELECT * FROM item WHERE id = 1;
UPDATE item SET quantity = quantity - 1, version = version + 1 WHERE id = 1 AND version = 预期版本号

5. 拓展思考

5.1. 悲观锁和排他锁、乐观锁和 CAS 分别有什么区别

悲观锁和乐观锁都是抽象概念,而且都是针对并发更新场景提出的,物理上不存在对应的锁。

悲观锁,去查数据的时候都悲观地认为别人会修改,所以每次查数据时直接上锁。排他锁是悲观锁的一种实现方案

乐观锁,相对悲观锁而言,查数据时认为一般不会被修改,所以只在更新数据时检测冲突。CAS 是乐观锁的一种具体实现

5.2. 悲观锁和乐观锁适用场景

写多读少用悲观锁,读多写少用乐观锁

举个例子,假设有10万并发,其中有几个是更新操作,其它都是读操作,这时候就特别适合使用乐观锁。对于更新操作,由于请求数较少,CAS 冲突概率就小,大部分都是成功的。对于读操作,由于没有加锁,就没有性能响应

假设有10万并发,有几个是读操作,其它都是写操作。如果使用乐观锁,CAS 冲突概率极大,大部分都是更新失败。如果还有循环不停地进行 CAS 操作,一个是应用的 CPU 开销过大,一个是给数据库带来过多的并发,严重影响性能。这时候就使用悲观锁,直接上锁。

5.3. 乐观锁是否必须加版本号或时间戳字段

如果 CAS 业务上存在 ABA 问题,那么就得加版本号或时间戳字段。

如果不存在 ABA 问题的话,直接通过业务字段本身来检测冲突即可,没有必要再引入额外字段

  • 6
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值