场景
在日常开发中,我们经常需要编写 数据更新业务(下面引文中是我自己对 数据更新 的定义),其核心逻辑如下:
- 数据校验,如 库存是否有剩余,队伍人数是否已达到上限
- 更新数据
数据更新 分为 修改数据 和 新增记录
修改数据:直接修改表中代表数据量的字段,如 商品库存减1
新增记录:一般是多对多的关系表,通过统计表中记录数来表示某种意义的数据,如 用户队伍关系表,通过队伍 id 统计出某一队伍的人数;新增记录,记录的统计数据更新,对应具体意义数据也更新,如 用户队伍关系表新增一条数据,某一队伍人数加1
在编写数据更新业务时,需要注意以下2个问题:
- 多线程安全问题
- 事务
多线程安全问题
校验数据、更新数据 是先后执行,不是作为原子操作,当多线程并发访问时就可能发生多线程安全问题,如超卖问题。
并发问题有以下2种解决方法:悲观锁,乐观锁。
悲观锁
悲观锁 认为多线程安全问题一定会发生,因此需要先获取锁,再执行数据更新业务,确保线程串行执行。
将悲观锁分为两类:单机锁、分布式锁
单机锁
单机锁 只对单个 JVM 的线程有效,如果项目部署在单台机器上,可以使用单机锁。
常用的单机锁有 synchronized;
synchronized 使用注意:锁对象必须是同一对象。常用的技巧就是 字符串调用 intern() 方法返回常量池中同一个 String 引用,确保同一个对象,示例如下:
// 加锁粒度是同一个用户的多个线程
Long userId = ...... // 用户id
// 加intern()返回的是同一个对象引用
// 不加intern()每次都会new一个String
synchronized(userId.toString().intern()) {
}
分布式锁
如果项目是集群部署,可以使用分布式锁。
常用的分布式锁有 Redisson 的 RLock。
加锁注意
-
加锁范围:要覆盖 校验数据、更新数据 两部分;加锁范围尽量小。
如 扣减库存业务,先判断库存是否有剩余,再扣减库存;加锁范围是判断+扣减;如果加锁范围只是判断,还是有线程安全问题。
-
锁的粒度:将锁的粒度尽量控制小些,这样性能才好;因为锁的粒度越大,串行执行的线程越多,性能越差。
乐观锁
乐观锁 认为线程安全问题不一定发生,因此不加锁,只是在修改数据时判断是否有其他线程对数据做了修改。如果没有修改,则说明数据是安全的,可以进行修改。
使用乐观锁注意:只适用于 修改数据,不可用于新增记录。
乐观锁的两种实现方法:
-
版本号法:给数据增加一个版本号字段,每次修改操作版本号 + 1,就可以通过版本号来判断数据是否有被修改。
-
CAS:是对乐观锁的简化,即直接用一个每次都会查询和修改的字段来代替版本号,下面用扣减库存来演示CAS
// 先查询库存 int oldStock = getStock(); // 再以oldStock为条件判断库存是否修改 update tb set `stock` = `stock` - 1 where `stock` = oldStock and 查询条件;
上面这种方式的缺点是成功的概率太低,因此改为使用下面的方法。
只要库存有剩余,即 stock > 0,就可以扣减库存,因此只要在修改时加一条件:stock > 0 即可
update tb set `stock` = `stock` - 1 where `stock` > 0 and 查询条件;
事务
一段业务逻辑中,如果需要进行 2次以上的 增删改 操作,就要考虑是否将这些操作作为一个事务处理。
Spring 的 @Transactional
可以非常方便地实现事务(声明式事务),@Transactional
有以下注意事项:
@Transactional
注意事项详细见文章:@Transactional
-
每次 增删改 操作后,要判断当前操作是否成功,如果失败,手动抛出自定义业务异常,让事务回滚,还原之前的操作(
@Transactional(rollbackFor = Exception.class)
抛出指定的异常类型,事务回滚)。 -
最小粒度是作用在方法上。如果想要给一部分代码块增加事务的话,那就需要把这个部分代码块单独抽取出来作为一个方法。
-
@Transactional
标注的方法应是 public 修饰 -
同一个类中方法调用,会导致
@Transactional
失效;正确的做法应是 不要直接调用,而是 先获取代理对象,再通过代理对象调用方法。 -
在事务方法内部 try…catch 捕获异常 导致事务失效
但是,如果是在事务方法外部 try…catch 并不会导致事务失效
事务方法内部加锁的问题
@Transactional
标注的事务方法,在方法内部加锁(悲观锁),可能会导致问题:当前方法事务还没有提交,但是锁已经释放;这样后面的线程拿到锁后,由于前面线程的事务还未提交,数据库的数据未更新,所以后面线程数据校验就会出错,造成多线程安全问题。
因此,应该将锁的范围覆盖整个事务方法,这样就能确保是先提交的事务,再释放锁。
示例:
事务方法内部加锁
@Transactional
public void txMethod() {
synchronized(xxx){
}
}
锁的范围覆盖整个事务方法:
// 事务方法
@Transactional
public void txMethod() {
}
// synchronized代码块内调用txMethod
public void test() {
synchronized(xxx){
通过代理对象调用txMethod
}
}
如果有帮助的话,可以点个赞支持一下嘛
🙏