写点什么

聊聊乐观锁 & 悲观锁

作者:Steven
  • 2022 年 7 月 10 日
  • 本文字数:2363 字

    阅读完需:约 8 分钟

基本概念

乐观锁:乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据;如果别人修改了数据则放弃操作,否则执行操作。

悲观锁:悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。


乐观锁适用于写比较少的场景,悲观锁适用于写比较多的场景。


参考维基百科的定义,乐观锁和悲观锁是关系数据库管理系统和软件事务内存系统的并发控制方法。可以理解为涉及到并发操作数据的场景都是适用的。


乐观锁的实现方式:CAS(Compare And Swap)

参考维基百科的定义,CAS 是多线程中用于实现同步的原子指令。它将内存位置的内容与指定值进行比较,如果相同则修改内存位置的内容为新的给定值。原子性确保这个新值是基于最新的信息。如果内存位置的值在此期间被其它线程更新了,则不会执行修改。整个操作的结果必须返回是否执行了修改。


参考下面的伪代码

function cas(p: pointer to int, old: int, new: int) is    if *p ≠ old        return false
*p ← new
return true
复制代码


CAS 是一种通过硬件实现并发安全的常用技术,底层通过利用 CPU 的 CAS 指令实现的。比如在 intel 的 CPU 中,使用的是 cmpxchg 指令。各种编程语言基于 CPU 的 CAS 指令实现自己的操作。


Java 借助 JNI 实现自己的 CAS,比如 AtomicInteger 类

public class AtomicInteger extends Number implements java.io.Serializable {    private volatile int value;    public final int get() {        return value;    }    public final int getAndIncrement() {        for (;;) {            int current = get();            int next = current + 1;            if (compareAndSet(current, next))                return current;        }    }    public final boolean compareAndSet(int expect, int update) {        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);    }}
复制代码


C# 则是定义了一组重载的 System.Threading.Interlocked.CompareExchange 方法,具体的不描述了。


关系数据库的乐观锁/CAS

SELECT id, name, version FROM student WHERE id = 1;
UPDATE student SET name = 'Tom',version = 2 WHERE id = 1 AND version = 1;
复制代码

这是采用了版本号的方式,还可以使用时间戳实现。


CAS 存在的问题

ABA 问题

在读旧值和和尝试 CAS 之间,其它线程把值修改了两次或多次,即 A —> B —> A,虽然值看起来还是原来的值,但语义是不同的。


一些场景下是没有影响的,但有一些场景却是问题的。

比如你的银行卡里有 100 块钱,兜里有 100 块钱,你花了 100 买了东西,又把卡里 100 取出来放在兜里,这时兜里的还是 100,但与此前的 100 的意义已经不一样了,你现在总共只有 100 块钱了。

再比如,堆栈里的值:1,2,1,3,1。线程 A 看栈顶是 1,线程 B 连续出栈两次,线程 A 再看栈顶还是 1。但其实已经不是前面的那个 1 了,栈里对象的数量也变了。

ABA 问题的解决方案

版本号,值的每次变化,版本号累加 1,中间如果有别人修改了,则版本号会有变化,比较的时候就会失败不会执行更新操作。

对于值和版本号,可以是两个单独的值;也可以合成一个值,比如值是 32 位,定义成 64 位,另外的 32 位用于存储版本号。

高并发下的竞争资源问题

高并发情况下,有可能 CAS 一直失败,然后再一直重试,这样会给 CPU 带来比较大的开销。基于此,需要定义合理的重试阈值,超出则结束重试。

这里有伪命题的嫌疑,最上面已经提过“乐观锁适用于写比较少的场景”,所以高并发写的场景需要慎重考虑是否要使用。


悲观锁

乐观锁其实并不是锁,解决的是无锁并发问题。悲观锁则是会锁住数据或代码块。


Java 语言可以通过 synchronized 关键字,或者一系列的 lock 对象。synchronized 用来修饰方法,也可用在特定的代码块上。lock 对象则可以做更多细节控制,还有读写优化的锁等等。


C# 也有 lock 关键字,Monitor 类,读写锁等等。


关系数据库的悲观锁,基于行排它锁

通过 select for update 为数据加上排它锁,直到事务提交或回滚才会释放。

begin;SELECT id, name FROM student WHERE id = 1 for update;UPDATE student SET name = 'Tom' WHERE id = 1;commit;
复制代码


分布式锁

基于编程语言的 CAS、synchronized 关键字、lock 对象等的是进程内锁,控制跨线程访问共享资源。分布式场景下,需要控制跨进程访问资源,要用到分布式锁。

一般来说以下几种方式:

基于数据库

上面提到的数据库的乐观锁/悲观锁实现方式


利用数据库的唯一索引约束特性,要加锁时往表里插入一条数据,插入成功则获得锁,释放锁时把这条数据删除即可。

相应的需要考虑锁超时机制处理、插入失败要有重试,重试阈值、还有可用性考虑。


基于分布式一致性算法

Zookeeper、Etcd,下面以 Zookeeper 为例。

使用 Zookeeper 的临时有序节点,判断节点序号最小的获得锁,释放锁时删除这个节点。

Watch 机制可以监听节点变化,其它等待的客户端并不需要轮询锁是否可用,等通知即可。


基于分布式缓存

Redis、Memcached,下面以 Redis 为例。

单机

加锁:执行 SET lock_key unique_value NX PX milliseconds 命令。

释放锁:要先判断 unique_value 是否相等,避免误释放,然后才删除掉这个 key。为保证原子性,需要使用 Lua 脚本来包装实现释放锁的操作。

集群

Redis 的开发者 Antirez 提出了分布式锁算法 Redlock。


常见的是基于 Redis 和 Zookeeper 的实现。

基于数据库性能较差,如果不想维护中间件可以考虑。

Redis 的性能是最好的,但可靠性不如 Zookeeper,Zookeeper 的可靠性一致性更好,但性能就差一些了。


参考资料

https://en.wikipedia.org/wiki/Compare-and-swap

https://en.wikipedia.org/wiki/Optimistic_concurrency_control

Redis 核心技术与实战(极客时间)


发布于: 刚刚阅读数: 4
用户头像

Steven

关注

还未添加个人签名 2008.07.18 加入

还未添加个人简介

评论

发布
暂无评论
聊聊乐观锁 & 悲观锁_Steven_InfoQ写作社区