前言:
在并发访问情况下, 可能会出现脏读, 不可重复读和幻读等读现象, 为了应对这些问题, 主流数据库都提供了锁机制, 并引入了事务隔离级别的概念. 数据库管理系统 (DBMS) 中的并发控制的任务是确保在多个事务同时存取数据库中同一数据时不破坏事务的隔离性和统一性以及数据库的统一性.
乐观并发控制 (乐观锁) 和悲观并发控制 (悲观锁) 是并发控制主要采用的技术手段. 无论是悲观锁还是乐观锁, 都是人们定义出来的概念, 可以认为是一种思想. 其实不仅仅是关系型数据库系统中有乐观锁和悲观锁的概念, 像 memcache,hibernate,tair 等都有类似的概念.
本文中也将深入分析一下乐观锁的实现机制, 介绍什么是 CAS,CAS 的应用以及 CAS 存在的问题等.
并发控制
在计算机科学, 特别是程序设计, 操作系统, 多处理机和数据库等领域, 并发控制 (Concurrency control) 是确保及时纠正由并发操作导致的错误的一种机制.
数据库管理系统 (DBMS) 中的并发控制的任务是确保在多个事务同时存取数据库中同一数据时不破坏事务的隔离性和统一性以及数据库的统一性. 下面举例说明并发操作带来的数据不一致性问题:
现有两处火车票售票点, 同时读取某一趟列车车票数据库中车票余额为 X. 两处售票点同时卖出一张车票, 同时修改余额为 X -1 写回数据库, 这样就造成了实际卖出两张火车票而数据库中的记录却只少了一张. 产生这种情况的原因是因为两个事务读入同一数据并同时修改, 其中一个事务提交的结果破坏了另一个事务提交的结果, 导致其数据的修改被丢失, 破坏了事务的隔离性. 并发控制要解决的就是这类问题.
封锁, 时间戳, 乐观并发控制 (乐观锁) 和悲观并发控制 (悲观锁) 是并发控制主要采用的技术手段.
一, 数据库的锁
锁
当并发事务同时访问一个资源时, 有可能导致数据不一致, 因此需要一种机制来将数据访问顺序化, 以保证数据库数据的一致性. 锁就是其中的一种机制.
在计算机科学中, 锁是在执行多线程时用于强行限制资源访问的同步机制, 即用于在并发控制中保证对互斥要求的满足.
锁的分类(oracle)
一, 按操作划分, 可分为 DML 锁, DDL 锁
二, 按锁的粒度划分, 可分为表级锁 http://www.hollischuang.com/archives/914 , 行级锁 http://www.hollischuang.com/archives/914 , 页级锁 http://www.hollischuang.com/archives/914 (MySQL)
三, 按锁级别划分, 可分为共享锁 http://www.hollischuang.com/archives/923 , 排他锁 http://www.hollischuang.com/archives/923
四, 按加锁方式划分, 可分为自动锁, 显示锁
五, 按使用方式划分, 可分为乐观锁 http://www.hollischuang.com/archives/934 , 悲观锁 http://www.hollischuang.com/archives/934
DML 锁(data locks, 数据锁), 用于保护数据的完整性, 其中包括行级锁(Row Locks (TX 锁)), 表级锁(table lock(TM 锁)).
DDL 锁(dictionary locks, 数据字典锁), 用于保护数据库对象的结构, 如表, 索引等的结构定义. 其中包排他 DDL 锁(Exclusive DDL lock), 共享 DDL 锁(Share DDL lock), 可中断解析锁(Breakable parse locks)
1.1 锁机制
常用的锁机制有两种:
1, 悲观锁: 假定会发生并发冲突, 屏蔽一切可能违反数据完整性的操作. 悲观锁的实现, 往往依靠底层提供的锁机制; 悲观锁会导致其它所有需要锁的线程挂起, 等待持有锁的线程释放锁.
2, 乐观锁: 假设不会发生并发冲突, 每次不加锁而是假设没有冲突而去完成某项操作, 只在提交操作时检查是否违反数据完整性. 如果因为冲突失败就重试, 直到成功为止. 乐观锁大多是基于数据版本记录机制实现. 为数据增加一个版本标识, 比如在基于数据库表的版本解决方案中, 一般是通过为数据库表增加一个 "version" 字段来实现. 读取出数据时, 将此版本号一同读出, 之后更新时, 对此版本号加一. 此时, 将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对, 如果提交的数据版本号大于数据库表当前版本号, 则予以更新, 否则认为是过期数据.
乐观锁的缺点是不能解决脏读的问题.
在实际生产环境里边, 如果并发量不大且不允许脏读, 可以使用悲观锁解决并发问题; 但如果系统的并发非常大的话, 悲观锁定会带来非常大的性能问题, 所以我们就要选择乐观锁定的方法.
二, 悲观锁与乐观锁详解
2.1 悲观锁
在关系数据库管理系统里, 悲观并发控制 (又名 "悲观锁",Pessimistic Concurrency Control, 缩写 "PCC") 是一种并发控制的方法. 它可以阻止一个事务以影响其他用户的方式来修改数据. 如果一个事务执行的操作都某行数据应用了锁, 那只有当这个事务把锁释放, 其他事务才能够执行与该锁冲突的操作.
悲观并发控制主要用于数据争用激烈的环境, 以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中.
悲观锁, 正如其名, 它指的是对数据被外界 (包括本系统当前的其他事务, 以及来自外部系统的事务处理) 修改持保守态度(悲观), 因此, 在整个数据处理过程中, 将数据处于锁定状态. 悲观锁的实现, 往往依靠数据库提供的锁机制 (也只有数据库层提供的锁机制才能真正保证数据访问的排他性, 否则, 即使在本系统中实现了加锁机制, 也无法保证外部系统不会修改数据)
在数据库中, 悲观锁的流程如下:
在对任意记录进行修改前, 先尝试为该记录加上排他锁 http://www.hollischuang.com/archives/923 (exclusive locking).
如果加锁失败, 说明该记录正在被修改, 那么当前查询可能要等待或者抛出异常. 具体响应方式由开发者根据实际需要决定.
如果成功加锁, 那么就可以对记录做修改, 事务完成后就会解锁了.
其间如果有其他对该记录做修改或加排他锁的操作, 都会等待我们解锁或直接抛出异常.
MySQL InnoDB 中使用悲观锁:
要使用悲观锁, 我们必须关闭 MySQL 数据库的自动提交属性, 因为 MySQL 默认使用 autocommit 模式, 也就是说, 当你执行一个更新操作后, MySQL 会立刻将结果进行提交. set autocommit=0;
- //0. 开始事务
- begin;/begin work;/start transaction; (三者选一就可以)
- //1. 查询出商品信息
- select status from t_goods where id=1 for update;
- //2. 根据商品信息生成订单
- insert into t_orders (id,goods_id) values (null,1);
- //3. 修改商品 status 为 2
- update t_goods set status=2;
- //4. 提交事务
- commit;/commit work;
上面的查询语句中, 我们使用了 select...for update 的方式, 这样就通过开启排他锁 http://www.hollischuang.com/archives/923 的方式实现了悲观锁. 此时在 t_goods 表中, id 为 1 的 那条数据就被我们锁定了, 其它的事务必须等本次事务提交之后才能执行. 这样我们可以保证当前的数据不会被其它事务修改.
上面我们提到, 使用 select...for update 会把数据给锁住, 不过我们需要注意一些锁的级别, MySQL InnoDB 默认行级锁 http://www.hollischuang.com/archives/914 . 行级锁都是基于索引的, 如果一条 SQL 语句用不到索引是不会使用行级锁的, 会使用表级锁把整张表锁住, 这点需要注意.
优点与不足
悲观并发控制实际上是 "先取锁再访问" 的保守策略, 为数据处理的安全提供了保证. 但是在效率方面, 处理加锁的机制会让数据库产生额外的开销, 还有增加产生死锁的机会; 另外, 在只读型事务处理中由于不会产生冲突, 也没必要使用锁, 这样做只能增加系统负载; 还有会降低了并行性, 一个事务如果锁定了某行数据, 其他事务就必须等待该事务处理完才可以处理那行数
2.2 乐观锁
在关系数据库管理系统里, 乐观并发控制 (又名 "乐观锁",Optimistic Concurrency Control, 缩写 "OCC") 是一种并发控制的方法. 它假设多用户并发的事务在处理时不会彼此互相影响, 各事务能够在不产生锁的情况下处理各自影响的那部分数据. 在提交数据更新之前, 每个事务会先检查在该事务读取数据后, 有没有其他事务又修改了该数据. 如果其他事务有更新的话, 正在提交的事务会进行回滚. 乐观事务控制最早是由孔祥重 (H.T.Kung) 教授提出.
乐观锁( Optimistic Locking ) 相对悲观锁而言, 乐观锁假设认为数据一般情况下不会造成冲突, 所以在数据进行提交更新的时候, 才会正式对数据的冲突与否进行检测, 如果发现冲突了, 则让返回用户错误的信息, 让用户决定如何去做.
相对于悲观锁, 在对数据库进行处理的时候, 乐观锁并不会使用数据库提供的锁机制. 一般的实现乐观锁的方式就是记录数据版本.
数据版本, 为数据增加的一个版本标识. 当读取数据时, 将版本标识的值一同读出, 数据每更新一次, 同时对版本标识进行更新. 当我们提交更新的时候, 判断数据库表对应记录的当前版本信息与第一次取出来的版本标识进行比对, 如果数据库表当前版本号与第一次取出来的版本标识值相等, 则予以更新, 否则认为是过期数据.
实现数据版本有两种方式, 第一种是使用版本号, 第二种是使用时间戳.
使用版本号实现乐观锁
使用版本号时, 可以在数据初始化时指定一个版本号, 每次对数据的更新操作都对版本号执行 + 1 操作. 并判断当前版本号是不是该数据的最新的版本号.
1. 查询出商品信息
select (status,status,version) from t_goods where id=#{id}
2. 根据商品信息生成订单
3. 修改商品 status 为 2
- update t_goods
- set status=2,version=version+1
- where id=#{
- id
- } and version=#{
- version
- };
优点与不足
乐观并发控制相信事务之间的数据竞争 (data race) 的概率是比较小的, 因此尽可能直接做下去, 直到提交的时候才去锁定, 所以不会产生任何锁和死锁. 但如果直接简单这么做, 还是有可能会遇到不可预期的结果, 例如两个事务都读取了数据库的某一行, 经过修改以后写回数据库, 这时就遇到了问题.
三, CAS 详解
在说 CAS 之前, 我们不得不提一下 Java 的线程安全问题.
线程安全:
众所周知, Java 是多线程的. 但是, Java 对多线程的支持其实是一把双刃剑. 一旦涉及到多个线程操作共享资源的情况时, 处理不好就可能产生线程安全问题. 线程安全性可能是非常复杂的, 在没有充足的同步的情况下, 多个线程中的操作执行顺序是不可预测的.
Java 里面进行多线程通信的主要方式就是共享内存的方式, 共享内存主要的关注点有两个: 可见性和有序性. 加上复合操作的原子性, 我们可以认为 Java 的线程安全性问题主要关注点有 3 个: 可见性, 有序性和原子性.
Java 内存模型 http://www.hollischuang.com/archives/1003 (JMM)解决了可见性和有序性的问题, 而锁解决了原子性的问题. 这里不再详细介绍 JMM 及锁的其他相关知识. 但是我们要讨论一个问题, 那就是锁到底是不是有利无弊的?
3.1 锁存在的问题
Java 在 JDK1.5 之前都是靠 synchronized 关键字保证同步的, 这种通过使用一致的锁定协议来协调对共享状态的访问, 可以确保无论哪个线程持有共享变量的锁, 都采用独占的方式来访问这些变量. 独占锁其实就是一种悲观锁, 所以可以说 synchronized 是悲观锁.
悲观锁机制存在以下问题:
1) 在多线程竞争下, 加锁, 释放锁会导致比较多的上下文切换和调度延时, 引起性能问题.
2) 一个线程持有锁会导致其它所有需要此锁的线程挂起.
3) 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置, 引起性能风险.
而另一个更加有效的锁就是乐观锁. 所谓乐观锁就是, 每次不加锁而是假设没有冲突而去完成某项操作, 如果因为冲突失败就重试, 直到成功为止.
与锁相比, volatile 变量是一个更轻量级的同步机制, 因为在使用这些变量时不会发生上下文切换和线程调度等操作, 但是 volatile 不能解决原子性问题, 因此当一个变量依赖旧值时就不能使用 volatile 变量. 因此对于同步最终还是要回到锁机制上来.
乐观锁
乐观锁 ( Optimistic Locking) 其实是一种思想. 相对悲观锁而言, 乐观锁假设认为数据一般情况下不会造成冲突, 所以在数据进行提交更新的时候, 才会正式对数据的冲突与否进行检测, 如果发现冲突了, 则让返回用户错误的信息, 让用户决定如何去做.
上面提到的乐观锁的概念中其实已经阐述了他的具体实现细节:
主要就是两个步骤: 冲突检测和数据更新.
其实现方式有一种比较典型的就是 Compare and Swap(CAS).
3.2 CAS
CAS 是项乐观锁技术, 当多个线程尝试使用 CAS 同时更新同一个变量时, 只有其中一个线程能更新变量的值, 而其它线程都失败, 失败的线程并不会被挂起, 而是被告知这次竞争中失败, 并可以再次尝试.
CAS 操作包含三个操作数 -- 内存位置 (V), 预期原值(A) 和新值(B). 如果内存位置的值与预期原值相匹配, 那么处理器会自动将该位置值更新为新值. 否则, 处理器不做任何操作. 无论哪种情况, 它都会在 CAS 指令之前返回该位置的值.(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功, 而不提取当前值.)CAS 有效地说明了 "我认为位置 V 应该包含值 A; 如果包含该值, 则将 B 放到这个位置; 否则, 不要更改该位置, 只告诉我这个位置现在的值即可." 这其实和乐观锁的冲突检查 + 数据更新的原理是一样的.
这里再强调一下, 乐观锁是一种思想. CAS 是这种思想的一种实现方式.
3.3 Java 对 CAS 的支持
JDK 5 之前 Java 语言是靠 synchronized 关键字保证同步的, 这是一种独占锁, 也是是悲观锁. j 在 JDK1.5 中新增 java.util.concurrent(J.U.C)就是建立在 CAS 之上的. 相对于对于 synchronized 这种阻塞算法, CAS 是非阻塞算法的一种常见实现. 所以 J.U.C 在性能上有了很大的提升.
现代的 CPU 提供了特殊的指令, 允许算法执行读 - 修改 - 写操作, 而无需害怕其他线程同时修改变量, 因为如果其他线程修改变量, 那么 CAS 会检测它(并失败), 算法可以对该操作重新计算. 而 compareAndSet() 就用这些代替了锁定.
我们以 java.util.concurrent 中的 AtomicInteger 为例, 看一下在没有锁的情况下是如何保证线程安全的. 主要理解 getAndIncrement 方法, 该方法的作用相当于 ++i 操作.
- 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);
- }
字段 value 需要借助 volatile 原语, 保证线程间的数据是可见的(共享的). 这样在获取变量的值的时候才能直接读取. 然后来看看 ++i 是怎么做到的. getAndIncrement 采用了 CAS 操作, 每次从内存中读取数据然后将此数据和 + 1 后的结果进行 CAS 操作, 如果成功就返回结果, 否则重试直到成功为止. 而 compareAndSet 利用 JNI 来完成 CPU 指令的操作.
- public final boolean compareAndSet(int expect, int update) {
- return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
- }
整体的过程就是这样子的, 利用 CPU 的 CAS 指令, 同时借助 JNI 来完成 Java 的非阻塞算法. 其它原子操作都是利用类似的特性完成的.
而整个 J.U.C 都是建立在 CAS 之上的, 因此对于 synchronized 阻塞算法, J.U.C 在性能上有了很大的提升.
3.4 CAS 会导致 "ABA 问题":
ABA 问题:
aba 实际上是乐观锁无法解决脏数据读取的一种体现. CAS 算法实现一个重要前提需要取出内存中某时刻的数据, 而在下时刻比较并替换, 那么在这个时间差类会导致数据的变化.
比如说一个线程 one 从内存位置 V 中取出 A, 这时候另一个线程 two 也从内存中取出 A, 并且 two 进行了一些操作变成了 B, 然后 two 又将 V 位置的数据变成 A, 这时候线程 one 进行 CAS 操作发现内存中仍然是 A, 然后 one 操作成功. 尽管线程 one 的 CAS 操作成功, 但是不代表这个过程就是没有问题的.
部分乐观锁的实现是通过版本号 (version) 的方式来解决 ABA 问题, 乐观锁每次在执行数据的修改操作时, 都会带上一个版本号, 一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行 + 1 操作, 否则就执行失败. 因为每次操作的版本号都会随之增加, 所以不会出现 ABA 问题, 因为版本号只会增加不会减少.
如果链表的头在变化了两次后恢复了原值, 但是不代表链表就没有变化. 因此 AtomicStampedReference/AtomicMarkableReference 就很有用了.
AtomicMarkableReference 类描述的一个 < Object,Boolean > 的对, 可以原子的修改 Object 或者 Boolean 的值, 这种数据结构在一些缓存或者状态描述中比较有用. 这种结构在单个或者同时修改 Object/Boolean 的时候能够有效的提高吞吐量.
AtomicStampedReference 类维护带有整数 "标志" 的对象引用, 可以用原子方式对其进行更新. 对比 AtomicMarkableReference 类的 < Object,Boolean>,AtomicStampedReference 维护的是一种类似 < Object,int > 的数据结构, 其实就是对对象 (引用) 的一个并发计数(标记版本戳 stamp). 但是与 AtomicInteger 不同的是, 此数据结构可以携带一个对象引用(Object), 并且能够对此对象和计数同时进行原子操作.
REFERENCE:
整理自以下博客:
- http://www.hollischuang.com/archives/934
- http://www.hollischuang.com/archives/1537 http://www.hollischuang.com/archives/1537
- http://www.cnblogs.com/Mainz/p/3546347.html
- http://www.digpage.com/lock.html
- https://chenzhou123520.iteye.com/blog/1863407 https://chenzhou123520.iteye.com/blog/1863407
- https://chenzhou123520.iteye.com/blog/1860954
来源: https://www.cnblogs.com/X-knight/p/10669934.html