Follow me on GitHub

基本概念 | 乐观锁、悲观锁

乐观锁和悲观锁并非特定框架或技术实现,它们是两种并发控制思想,在不同领域中,它们往往披着掩人耳目的、各不相同的外衣,容易让人忽略它们的本质。

本文将介绍并发控制的背景,乐观锁、悲观锁的基本概念,以及它们在数据库和 Java 并发编程中的实际应用。

背景篇

在计算机科学,特别是程序设计、操作系统、多处理机、数据库领域中,并发控制(concurrency control)是一种保证并发操作(concurrent operations)能产生 正确 结果的机制。

无论软件还是硬件,都由不同模块或组件组成:

  • 通过遵从特定的一致性规则(consistency rules),每个组件都可以正确运行。
  • 当组件并发运行,并通过消息、共享数据(内存或磁盘)交互时,单个组件的一致性可能被其他组件 破坏

广义上的并发控制通过提供规则、方法、设计方法学、理论等,以维护并发运行、互相交互的多个组件的一致性,进而保证整个系统的一致性和正确性。

并发控制可以得到一致、正确的系统,但有如下代价:

  • 并发控制会对系统施加操作约束(operation constraints),这些约束通常会在合理范围内降低系统 性能
  • 并发控制需要非常复杂的并发算法,而串行算法一般很简单

并发控制机制有如下几类:

  • 乐观并发控制(optimistic concurrency control - OCC),又称乐观锁
  • 悲观并发控制(pessimistic concurrency control - PCC),又称悲观锁
  • 锁(two-phase locking - 2PL)
  • 时间戳

概念篇

了解乐观锁、悲观锁的背景后,下面介绍两者的基本概念。

乐观锁

乐观锁中的乐观,是指对 并发更新 很乐观,即认为并发冲突是小概率事件,很少发生,只在 最后 更新共享数据时进行 并发冲突检测,若发现冲突,一般采用 失败重试 的策略恢复。

乐观锁特点:

  • 在数据竞争少,冲突少的场景中,即使发生并发冲突,并导致失败重试,重试成本往往 低于 最开始完全锁定数据,因此乐观锁比悲观锁 吞吐量 大;
  • 乐观锁响应速度更快,无论失败还是成功,都能立即获取结果;

如果并发更新频繁,又不恰当地使用了乐观锁,则无论吞吐量还是响应速度,都远远不如悲观锁。

悲观锁

与乐观锁类似,悲观是指对 并发更新 很悲观,认为并发冲突是大概率事件,会频繁发生,此时失败重试成本太高,所以采用排队等待更新的方式,消除并发更新。

悲观锁的特点:

  • 悲观锁一般借助系统提供的 排他锁(数据库锁、synchronized 等)实现,可能导致死锁;
  • 排他锁会阻塞 只读 操作,会降低响应速度;

在竞争激烈的环境中,排队更新的成本要低于失败重试。

应用篇

下面介绍乐观锁、悲观锁在数据库和 Java 并发编程中的实际应用。

数据库

乐观锁

乐观锁不使用数据库锁。

在数据库中实现乐观锁有两种常见的方式:

  • 版本号
  • 时间戳

两者原理类似,下面以版本号为例介绍,其基本流程如下:

  1. 每个表都需要添加 版本号 字段;
  2. 读取数据时获取版本号,并处理业务逻辑;
  3. 更新表时,验证数据库中该记录的 当前版本号 与第 2 步读取到的版本号是否一致:
    • 若一致则更新,并将版本号 + 1
    • 若不一致,则不更新

一个简单例子:

1
2
3
4
5
6
// 查询出商品信息
select (status,version) from goods where id=#{id}
// 生成订单,修改商品status2
update goods
set status=2,version=version+1 // 版本号 + 1
where id=#{id} and version=#{version}; // 校验版本号

使用时间戳只要将版本号替换为时间戳即可,其他步骤一致。

悲观锁

悲观锁利用数据库锁实现,达到对数据的 排他 访问。

悲观锁处理流程:

  1. 修改任意记录前,先为该记录加 排他锁
  2. 若加锁失败,说明该记录正在被占用,则由开发人员决定等待或抛出异常;
  3. 若加锁成功,则修改记录并提交事务,完成本次处理;
    • 期间若有其他修改该记录的请求,该请求将处于第 2 步

以 MySQL 为例:

1
2
3
4
5
6
7
8
9
10
//0.开始事务
begin;/begin work;/start transaction; (三者选一就可以)
//1.查询出商品信息
select status from goods where id=1 for update;
//2.根据商品信息生成订单
insert into t_orders (id,goods_id) values (null,1);
//3.修改商品status为2
update goods set status=2;
//4.提交事务
commit;/commit work;
  • 使用悲观锁,需要关闭 MySQL 自动提交功能:set autocommit = 0
  • select ... for update 开启排他锁;
  • MySQL 默认使用行级锁,行级锁基于索引,若 SQL 语句没有用到索引,则不会使用行级锁,而是使用表级锁;

Java 并发编程

乐观锁、悲观锁是并发控制思想,并非数据库独有的机制,Java 并发编程中也有它们的身影。

悲观锁

Java 中的锁主要有两类:

  • synchronized(监视器锁)
  • J.U.C Lock

其中监视器锁是典型的悲观锁,同一时刻只能有一个线程可以访问 synchronized 修饰的代码块、方法。

synchronized 是 Java 中最早出现的并发控制机制,可以保证:

  • 复合操作的原子性
  • 内存可见性
  • 有序性

但有如下缺点:

  • 上下文切换、线程调度的开销较大
  • 等待锁的线程会被 阻塞,不能做其他事情

乐观锁

synchronized 外,Java 还提供更加轻量级的 volatile 变量(参考 volatile 初探),但 volatile 只能保证:

  • 内存可见性
  • 有序性

无法保证:

  • 复合操作的完整性

这会导致无法用 volatile 实现一些常用工具,例如计数器或互斥体(mutex)。

volatile 不够通用,而 synchronized 又太重,而 CAS 介于两者之间,足够轻量级,又支持原子的更新操作,是很好的折中选择。

CAS(compare and swap)即比较并交换,是一种典型的乐观锁,并由 CPU 通过 CAS 指令直接支持。

Java 5 之前不能直接使用 CAS 指令,Java 5 通过 Unsafe 类公开了 intlong 和引用类型上的 CAS 操作。

处理器会提供一些 特殊指令,用于管理 共享数据 的并发访问,例如:

  • 原子的测试并设置(test and set)
  • 原子的获取并递增(fetch and increment)
  • 原子的比较并交换(compare and swap)

基于这些指令可以实现各种互斥体(mutex),通过这些互斥体又可以实现更复杂的并发对象。

目前大多数处理器选择的是 CAS 指令,该指令包含 3 个操作数:

  • 需要读写的内存位置 V
  • 进行比较的值 A
  • 准备写入的新值 B

CAS 指令执行时:

  • 当且仅当 V 的值等于 A 时,才会 原子 地将 B 写入 V
  • 若更新过程中检测中 V 的值不等于 A,则不做任何操作

无论是否更新成功,CAS 都返回 V 原有的值

CAS 指令本身是 原子 的,由 CPU 直接支持,在更新过程中可以执行 冲突检测

当多个线程使用 CAS 同时更新共享变量时,只有一个线程可以成功,其他线程都会失败,与 synchronized 效果基本相同;但与 synchronized 不同的是,竞争失败的线程不会 挂起,它们可以自由选择自己的行为,例如:

  • 重试
  • 执行一些恢复操作
  • 不做任何处理

这比粗暴地挂起线程灵活很多,而且会减少锁相关的活跃性风险。

J.U.C 的原子类(AtomicInteger 等)大量使用了 CAS 操作,而 J.U.C 大多数 类的实现都直接或间接地使用了这些原子类,因此即使你没有直接使用过 CAS,但你的代码几乎 100% 是建立在 CAS 操作基础上的。

CAS 并非完美无缺,例如可能出现 ABA 问题。


参考: