乐观锁是一种不会阻塞其他线程并发的机制,它不会使用数据库的锁进行实现,它的设计里面由于不阻塞其他线程,所以并不会引发线程频繁挂起和恢复,这样便能够提高并发能力,所以也有人把它称为非阻塞锁,那么它的机制是怎么样的呢?
乐观锁使用的是 CAS 原理,所以我们先来讨论 CAS 原理的内容。
在 CAS 原理中,对于多个线程共同的资源,先保存一个旧值(Old Value),比如进入线程后,查询当前存量为 100 个红包,那么先把旧值保存为 100,然后经过一定的逻辑处理。
当需要扣减红包的时候,先比较数据库当前的值和旧值是否一致,如果一致则进行扣减红包的操作,否则就认为它已经被其他线程修改过了,不再进行操作,CAS 原理流程如图 1 所示。
图 1 CAS原理
CAS 原理并不排斥并发,也不独占资源,只是在线程开始阶段就读入线程共享数据,保存为旧值。当处理完逻辑,需要更新数据的时候,会进行一次比较,即比较各个线程当前共享的数据是否和旧值保持一致。
如果一致,就开始更新数据;如果不一致,则认为该数据已经被其他线程修改了,那么就不再更新数据,可以考虑重试或者放弃。有时候可以重试,这样就是一个可重入锁,但是 CAS 原理会有一个问题,那就是 ABA 问题,下面先来讨论一下 ABA 问题。
对于乐观锁而言,我们之前讨论了存在 ABA 的问题,那么什么是 ABA 问题呢?下面看看表 1 的两个线程发生的场景。
时 刻 | 线程1 | 线程2 | 备 注 |
---|---|---|---|
T0 | —— | —— | 初始化 X=A |
T1 | 读入X=A | —— | —— |
T2 | —— | 读入X=A | —— |
T3 | 处理线程 1 的业务逻辑 | X=B | 修改共享变量为 B |
T4 | 处理线程 2 业务逻辑第一段 | 此时线程1在 X=B 的情况下运行逻辑 | |
T5 | X=A | 还原变量为 A | |
T6 | 因为判断 X=A,所以更新数据 | 处理线程 2 业务逻辑第二段 | 此时线程 1 无法知道线程 2 是否修改过 X,引发业务逻辑错误 |
T7 | —— | 更新数据 | —— |
在 T3 时刻,由于线程 2 修改了 X=B,此时线程 1 的业务逻辑依旧执行,但是到了 T5 时刻,线程 2 又把 X 还原为 A,那么到了 T6 时刻,使用 CAS 原理的旧值判断,线程 1 就会认为 X 值没有被修改过,于是执行了更新。
我们难以判定的是在 T4 时刻,线程 1 在 X=B 的时候,对于线程 1 的业务逻辑是否正确的问题。由于 X 在线程 2 中的值改变的过程为 A->B->A,才引发这样的问题,因此人们形象地把这类问题称为 ABA 问题。
ABA 问题的发生,是因为业务逻辑存在回退的可能性。如果加入一个非业务逻辑的属性,比如在一个数据中加入版本号(version),对于版本号有一个约定,就是只要修改 X 变量的数据,强制版本号(version)只能递增,而不会回退,即使是其他业务数据回退,它也会递增,那么 ABA 问题就解决了,如表 2 所示。
时刻 | 线程1 | 线程2 | 备 注 |
---|---|---|---|
T0 | —— | —— | 初始化 X=A,version=0 |
T1 | 读入X=A | —— | 线程1旧值:version=0 |
T2 | —— | 读入X=A | 线程2旧值:version=0 |
T3 | 处理线程1的业务逻辑 | X=B | 修改共享变量为 B,version=1 |
T4 | 处理线程 2 业务逻辑第一段 | —— | |
T5 | —— | X=A | 还原变量为A,version=2 |
T6 | 判断 version == 0,由于线程 2 两次更新数据,导致数据 version=2,所以不再更新数据 | 处理线程 2 业务逻辑第二段 | 此时线程 1 知道旧值 version 和当前 version 不一致,将不更新数据 |
T7 | —— | 更新数据 | —— |
只是这个 version 变量并不存在什么业务逻辑,只是为了记录更新次数,只能递增,帮助我们克服 ABA 问题罢了,有了这些理论,我们就可以开始使用乐观锁来完成抢红包业务了。
通过上述的讨论,我们清楚了 CAS 原理如何避免数据的不一致,如何规避 CAS 原理产生的 ABA 问题,在高并发的应用中使用 CAS 原理,我们称之为乐观锁。
为了顺利使用乐观锁,需要先在红包表(T_RED_PACKET)加入一个新的列版本号(version),这个字段在建表的时候已经建了,只是我们还没有使用,那么在 UserRedPacket.xml 的代码中加入新的方法,代码如下所示。
注意加粗的代码,在扣减红包的时候,增加了对版本号的判断,其次每次扣减都会对版本号加一,这样保证每次更新在版本号上有记录,从而避免 ABA 问题。
对于查询也不使用 for update 语句,避免锁的发生,这样就没有线程阻塞的问题了,这里在对应的 UserRedPacketDao 接口上加入对应方法,然后就可以在类 UserRedPacketServiceImpl 中新增方法 grapRedPacketForVersion(需要在其接口 UserRedPacketService 加上同样的方法),完成对应的逻辑即可,代码如下所示。
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int grapRedPacketForVersion(Long redPacketId, Long userId) {
// 获取红包信息,注意version值
RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
// 当前小红包库存大于0
if (redPacket.getstock() > 0) {
// 再次传入线程保存的version 旧值给SQL判断,是否有其他线程修改过数据
int update = redPacketDao.decreaseRedPacketForVersion(redPacketId,redPacket.getVersion());
// 如果没有数据更新,则说明其他线程已经修改过数据,本次抢红包失败
if (update == 0) {
return FAILED;
}
// 生成抢红包信息
UserRedPacket userRedPacket = new UserRedPacket();
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setUserId(userId);
userRedPacket.setAmount(redPacket.getUnitAmount());
userRedPacket.setNote ("抢红包"+ redPacketId);
//插入抢红包信息
int result = userRedPacketDao.grapRedPacket(userRedPacket);
return result;
}
//失败返回
return FAILED;
}
version 值一开始就保存到了对象中,当扣减的时候,再次传递给 SQL,让 SQL 对数据库的 version 和当前线程的旧值 version 进行比较。如果一致则插入抢红包的数据,否则就不进行操作。
为了进行测试,在控制器 UserRedPacketController 内新建方法,代码如下所示。
@RequestMapping(value = "/grapRedPacketForVersion")
@ResponseBody
public Map<String, Object> grapRedPacketForVersion(Long redPacketId, Long userId) {
// 抢红包
int result = UserRedPacketService.grapRedPacketForVersion(redPacketId, userId);
Map<String, Object> retMap = new HashMap<String, Object>();
boolean flag = result > 0;
retMap.put("success", flag);
retMap.put("message", flag ? "抢红包成功" : "抢红包失败");
return retMap;
}
修改 test.jsp 的 JSP 文件中 JavaScript 的 POST 请求地址和红包,id 对应上新增控制器的方法,便可以进行测试,结果如图 2 所示。
图 2 乐观锁测试结果
从图 2 中我们看到,经过 3 万次的抢夺,还会存在大量的红包,也就是存在大量的因为版本不一致的原因造成抢红包失败的请求,不过这个失败率太高了一点。有时候会容忍这个失败,这取决于业务的需要,因为允许用户自己再发起抢夺红包,比如微信抢红包也常常发生错误返回。再次对性能进行测试,如图 3 所示。
图 3 测试乐观锁性能
33 秒完成所有抢红包的功能,这个结果和不使用任何锁一样,但是还存在大量的红包因为版本并发的原因而没有被抢到,而且这个概率比较高,下面解决这个问题。
为了克服这个问题,提高成功率,还会考虑使用重入机制。也就是一旦因为版本原因没有抢到红包,则重新尝试抢红包,但是过多的重入会造成大量的 SQL 执行,所以目前流行的重入会加入两种限制,一种是按时间戳的重入,也就是在一定时间戳内(比如说 100 毫秒),不成功的会循环到成功为止,直至超过时间戳,不成功才会退出,返回失败。
另外一种是按次数,比如限定 3 次,程序尝试超过 3 次抢红包后,就判定请求失效,这样有助于提高用户抢红包的成功率,下面讨论如何重入。
因为乐观锁造成大量更新失败的问题,使用时间戳执行乐观锁重入,是一种提高成功率的方法,比如考虑在 100 毫秒内允许重入,把 UserRedPacketServiceImpl 中的方法 grapRedPacketForVersion 修改为以下代码。
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int grapRedPacketForVersion(Long redPacketId, Long userId) {
// 记录开始时间
long start = System.currentTimeMillis();
// 无限循环,等待成功或者时间满100亳秒退岀
while (true) {
// 获取循环当前时间
long end = System.currentTimeMillis();
// 当前时间己经超过100毫秒,返回失败
if (end - start > 100) {
return FAILED;
}
// 获取红包信息,注意version值
RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
// 当前小红包库存大于0
if (redPacket.getStock() > 0) {
// 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据
int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
// 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺
if (update == 0) {
continue;
}
// 生成抢红包信息
UserRedPacket UserRedPacket = new UserRedPacket();
UserRedPacket.setRedPacketId(redPacketId);
UserRedPacket.setUserId(userId);
UserRedPacket.setAmount(redPacket.getUnitAmount());
UserRedPacket.setNote("抢红包" + redPacketId);
// 插入抢红包信息
int result = userRedPacketDao.grapRedPacket(UserRedPacket);
return result;
} else {
// 一旦没有库存,则马上返回
return FAILED;
}
}
}
当因为版本号原因更新失败后,会重新尝试抢夺红包,但是会实现判断时间戳,如果时间戳在 100 毫秒内,就继续,否则就不再重新尝试,而判定失败,这样可以避免过多的 SQL 执行,维持系统稳定。乐观锁按时间戳重入,如图 4 所示。
图 4 乐观锁按时间戳重入
从结果来看,之前大量失败的场景消失了,也没有超发现象,3 万次尝试抢光了所有的红包,避免了总是失败的结果,但是有时候时间戳并不是那么稳定,也会随着系统的空闲或者繁忙导致重试次数不一。
有时候我们也会考虑限制重试次数,比如 3 次。下面再次改写 UserRedPacketServiceImpl 中的方法 grapRedPacketForVersion,代码如下所示。
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int grapRedPacketForVersion(Long redPacketId, Long userId) {
for (int i = 0; i < 3; i++) {
// 获取红包信息,注意version值
RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
// 当前小红包库存大于0
if (redPacket.getStock() > 0) {
// 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据
int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
// 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺
if (update == 0) {
continue;
}
// 生成抢红包信息
UserRedPacket UserRedPacket = new UserRedPacket();
UserRedPacket.setRedPacketId(redPacketId);
UserRedPacket.setUserId(userId);
UserRedPacket.setAmount(redPacket.getUnitAmount());
UserRedPacket.setNote("抢红包" + redPacketId);
// 插入抢红包信息
int result = userRedPacketDao.grapRedPacket(UserRedPacket);
return result;
} else {
// 一旦没有库存,则马上返回
return FAILED;
}
}
return FAILED;
}
通过 for 循环限定重试 3 次,3 次过后无论成败都会判定为失败而退出,这样就能避免过多的重试导致过多 SQL 被执行的问题,从而保证数据库的性能。为此笔者也进行了测试,如图 5 所示。
图 5 乐观锁按重试次数重入
显然效果很好,3 万次请求,所有红包都被抢到了,也没有发生超发现象,这样就可以消除大量的请求失败,避免非重入的时候大量请求失败的场景。
但是现在是使用数据库的情况,有时候并不想使用数据库作为抢红包时刻的数据保存载体,而是选择性能优于数据库的 Redis。
之前我们学习过 Redis 事务,也学习过 Redis 的 Lua 语言,它是一种原子性的操作,为此我们将绕开数据库用 Redis 处理高并发的请求。