一、问题描述
现有一个电商项目ac-mall-buy,该项目是单体应用架构而非微服务架构,但为了支撑更大的用户量,将该项目部署在3台web服务器上(服务A、服务B、服务C),并用Nginx做反向代理。
该项目有下单功能,下单后要更新产品库存,更新库存伪代码如下
@Transactional(rollbackFor = Exception.class)
public void updateProductStore(String productId){
//操作数据库,更新商品库存
}
由于在更新库存的方法上加了事务注解@Transactional(rollbackFor = Exception.class)
,在单体应用,单体部署的时候是没问题的,即使出现并发的情况,事务控制也能保证产品库存的一致性。
但如果是分布式部署,则会出现分布式事务的问题,事务注解@Transactional(rollbackFor = Exception.class)
只针对本地服务有效。如果现在服务A、服务B同时更新某一产品库存,就会出现数据不一致的问题。
二、并发的控制策略
控制并发采用的策略通常分为乐观锁和悲观锁。
乐观锁的定义: 顾名思义,对加锁持有一种乐观的态度,即先进行业务操作,不到最后一步不进行加锁,乐观地认为加锁一定会成功的,在最后一步更新数据的时候再进行加锁。乐观锁的核心算法是CAS(Compare And Swap,比较并交换),它涉及到三个操作数:内存值、预期值、新值。当且仅当预期值和内存值相等时才将内存值修改为新值。
悲观锁的定义: 正如其名字一样,悲观锁对数据加锁持有一种悲观的态度。因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。
简言之,
乐观锁: 不是在数据库端锁住的,而是程序端控制的。此时可以在mybatis中实现乐观锁机制。
悲观锁: 在数据库里面锁住,类似for update查询。
三、解决方案
3.1 采用乐观锁
如果是一些普通的非高并发的场景,可以使用乐观锁。乐观锁的实现通常有两种方式:版本号字段和时间戳字段。
补充:为了更好的用户体验,当发生并发更新失败后,可以加上重试机制继续完成业务。
3.1.1 版本(version)字段:
更新的时候给版本号字段加上1,然后UPDATE会返回一个更新结果的行数,通过这个行数去判断,如下所示:
UPDATE T_USER u
SET u.userName = #userName#, u.version = u.version + 1
WHERE u.userId = #userId# AND u.version = #version#
程序实现逻辑为:
if(rowsUpdated= =0)
{
throws new OptimisticLockingFailureException();
}
如果更新执行返回的数量是 0 表示产生并发问题了,则抛出乐观锁并发修改异常,需要重新获得最新的数据后再进行更新操作。
使用乐观锁方案的好处是,mybatis中已提供了实现乐观锁的插件 ,进行全局配置即可,及其简单方便。
3.1.2 时间戳(timestamps):
第二种实现方式和第一种差不多,同样是在需要乐观锁控制的table中增加一个字段,名称无所谓,字段类型使用时间戳(timestamp),和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。
此方案有缺点,就是当并发事务时间间隔小于当前系统平台的最小时间单位时,会发生覆盖前一个事务结果的问题。
3.2 用Redis做分布式锁
分布式锁本质上要实现的目标就是在 Redis 里面占一个“位”,当别的进程也要来占时,发现已经有人坐在那里了,就只好放弃或者稍后再试。
占位一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占位。先来先占, 用完了,再调用 del 指令释放位置。
127.0.0.1:6379> setnx userName alanchen
(integer) 1
127.0.0.1:6379> del userName
(integer) 1
但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样就会陷入死锁,锁永远得不到释放。于是我们在拿到锁之后,再给锁加上一个过期时间,比如 5s,这样即使中间出现异常也可以保证 5 秒之后锁会自动释放。
127.0.0.1:6379> setnx userName alanchen
(integer) 1
127.0.0.1:6379> expire userName 5
(integer) 1
127.0.0.1:6379> del userName
(integer) 1
但是以上逻辑还有问题。如果在 setnx 和 expire 之间服务器进程突然挂掉了,可能是因为机器掉电或者是被人为杀掉的,就会导致 expire 得不到执行,也会造成死锁。
这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。如果这两条指令可以一起执行就不会出现问题。也许你会想到用 Redis 事务来解决。但是这里不行,因为 expire 是依赖于 setnx 的执行结果的,如果 setnx 没抢到锁,expire 是不应该执行的。事务里没有 if-else 分支逻辑,事务的特点是一口气执行,要么全部执行要么一个都不执行。
Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx 和 expire 指令可以一起执行,解决了分布式锁的问题。
127.0.0.1:6379> setex userName 5 alanchen
OK
127.0.0.1:6379> get userName
(nil)
超时问题
Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。因为这时候第一个线程持有的锁过期了,临界区的逻辑还没有执行完,这个时候第二个线程就提前重新持有了这把锁,导致临界区代码不能得到严格的串行执行。
为了避免这个问题,Redis 分布式锁不要用于较长时间的任务。如果真的偶尔出现了,数据出现的小波错乱可能需要人工介入解决。
补充:Java可以直接用Redisson框架实现Redis分布式锁
网友评论