原创

关于synchronized、锁分段和事务的相关总结


上周项目最后一个版本之前,测试组那边进行了压测。暴露了很多由于高并发导致的数据库脏数据问题,现在把问题抽象出来做一个总结。

1. synchronized和锁分段

我们知道,如果一个方法中没有加锁的话,在并发情况下,是不安全的,所以在写业务代码时,我在需要同步的方法上加了个synchronized,在方法上加synchronized的话,锁是类的字节码对象,由于SpringBoot中都是单例的,所以不同的线程进来,拿到的都是同一把锁,这样就不会引起多路并发问题,如下:

@Service
public class TConcludeServiceImpl {
    @Transactional
	public synchronized void saveConclude(Long caseId) {
    	// 具体的业务逻辑
	} 
}

上述方法中,参数caseId是某个案件的id,不同的案件id是不同的,这种方式加锁有个问题是效率不高。因为所有案子进来,拿到的都是同一把锁,不仅仅是针对同一案件多并发。也就是说,所有案子多路并发进来,只能一个个处理,那么针对这个问题,可以进行锁分段处理来提升点效率。
锁分段就是说不同的案件,使用不同的锁,相同的案子,相同的锁。这样针对于同一个案子,就不会有多条脏数据出现。那如何实现呢?这里使用了InitializingBean接口,InitializingBean接口为bean提供了初始化方法的方式,它只包括afterPropertiesSet方法,凡是实现该接口的类,在初始化bean的时候会执行该方法。具体实现方式如下:

@Service
public class TConcludeServiceImpl implements InitializingBean {
	// 锁的个数
    private final static int LOCK_COUNT = 32;
    // 分段锁
    private static volatile Object[] LOCKS;
	
    @Transactional
	public void saveConclude(Long caseId) {
		synchronized (LOCKS[Math.abs(caseId.hashCode()) % LOCK_COUNT]) {
            // 具体的业务逻辑
        }
	} 
	
	@Override
    public void afterPropertiesSet() throws Exception {
        LOCKS = new Object[LOCK_COUNT];
        for (int i = 0; i < LOCK_COUNT; i++) {
            LOCKS[i] = new Object();
        }
    }
}

可以看出,实现InitializingBean接口,要重写afterPropertiesSet方法,在该方法中,初始化了32个对象,放到分段锁数组中保存。该方法在实例化TConcludeServiceImpl这个Bean之后会执行,也就是说,Spring在实例化TConcludeServiceImpl这个Bean之后,该对象中就存在了这32个锁对象了。
然后使用不同案件的id做哈希模32,就会得到一个索引,根据这个索引在分段锁数组中拿到对应的锁,这样不同的案件拿到的锁不同,效率会大大增加,当然了,理论上LOCK_COUNT越大,效率越高,因为caseId做哈希模LOCK_COUNT的值随着LOCK_COUNT的增大,更不容易重复。
但是问题并没有想象的那么简单,因为在高并发的情况下,这种加锁的方式,还是出现了数据库中同一个案子插入了多条数据,也就是说,在高并发的情况下(其实就并发了50路,还不算太高),同一个案子中有多路同时进来了,锁并没有起作用(并不是说这个锁加的不对)。

2. 事务的范围

这个问题排查了好几次,后来经过在网上搜索相关问题后,发现了问题的根源:事务。从上面方法中可以看到,方法上是加了事务的,那么也就是说,在执行该方法开始时,事务启动,执行完了后,事务关闭。但是不管是用synchronized还是锁分段的方式,其实根本原因是因为事务的范围比锁的范围大
也就是说,在加锁的那部分代码执行完之后,锁释放掉了,但是事务还没结束,此时另一个线程进来了,事务没结束的话,第二个线程进来时,数据库的状态和第一个线程刚进来是一样的。即由于mysql Innodb引擎的默认隔离级别是可重复读(在同一个事务里,SELECT的结果是事务开始时时间点的状态),线程二事务开始的时候,线程一还没提交完成,导致读取的数据还没更新。
找到了问题的根源后,就好解决了,针对这个问题,有几种可以解决的办法:

  • 1.把事务去掉,这样锁结束了,数据库就更新了,线程二进来就是更新后的数据库了。
  • 2.在调用该service方法的地方加锁,也就是说让锁的范围大于事务的范围即可。
  • 3.使用编程式事务,在锁释放之前手动把事务提交,更新数据库。这中方法还没尝试,但是理论上也是可行的,有空来研究一下。
Java
多线程
  • 作者:倪升武(联系作者)
  • 发表时间:2018-06-03 00:08
  • 版权声明:自由转载-非商用-非衍生-保持署名,转载请注明出处
  • 评论