记一次多线程事务的实践探索

1、多线程事务场景

在日常的开发工作中,会遇到这种业务场景,一次保存或修改操作涉及到多张表的插入和修改,同时还要满足事务,要么都插入成功或者插入失败;举例外卖系统中注册商家信息,假设有商家主信息表、商家地址信息表、商家收付款账户信息表、商家认证资料附件信息表、商家描述信息表、商家图片信息表;一个保存操作涉及到多张表,如果业务场景再复杂点,保存的时候还要通知到第三方系统;

在这种业务场景下,如果不想在一个事务里面同步执行完,运用多线程事务该怎么做呢

2、多线程事务

事务的四大特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。

多线程事务的环境下,每个线程都是一个单独的事务,acid都能得到保证,而在多线程事务中,难点就在于如何保证多个子事务的原子性,要么都成功要么都失败。

多线程事务实现(示例)

java 复制代码
public void futureAsyncInsert(CountDownLatch latch,AtomicBoolean flag){
    CompletableFuture<Void> future1 = CompletableFuture.runAsync(() -> {
       try {
          // 开启事务,必须放入try catch中
          TransactionStatus status = platformTransactionManager.getTransaction(new DefaultTransactionDefinition(TransactionDefinition.PROPAGATION_REQUIRES_NEW));  
          // 处理数据库添加操作
          insert1(latch,flag);
          // 等待其他线程数据库操作完成(可使用带参数的latch.await(),但无法适用于小流量的突增) 
          latch.await();
          // 如果全局标识为true,说明有子事务执行失败
          if (flag.get()) {
             // 当前事务回滚
             platformTransactionManager.rollback(status);
          } else {
             // 当前事务提交
             platformTransactionManager.commit(status);
          }
       } catch (CannotCreateTransactionException e) {
          log.error("insertInfo1 获取数据库连接异常:", e);
          flag.set(true);
          latch.countDown();
       } catch (Exception e) {
          log.error("insertInfo1 error:{}", e);
       }
    }, taskExecutor);

    CompletableFuture<Void> future2 = CompletableFuture.runAsync(() -> {
       try {
          TransactionStatus status = platformTransactionManager.getTransaction(new DefaultTransactionDefinition(TransactionDefinition.PROPAGATION_REQUIRES_NEW));   
          insert2(latch,flag);
          latch.await();
          if (flag.get()) {
             platformTransactionManager.rollback(status);
          } else {
             platformTransactionManager.commit(status);
          }
       } catch (CannotCreateTransactionException e) {
          log.error("insertInfo2 获取数据库连接异常:", e);
          flag.set(true);
          latch.countDown();
       } catch (Exception e) {
          log.error("insertInfo2 error:{}", e);
       }
    }, taskExecutor);
    .............
    CompletableFuture.allOf(future1, future2, ......).join();
}

数据添加方法

java 复制代码
public void insert1(CountDownLatch latch, AtomicBoolean flag) {
    try {
       // 添加数据操作
       。。。。
    } catch (Exception e) {
       log.error("inser1 error:{}", e);
       flag.set(true);
    } finally {
       // 释放当前线程的latch
       latch.countDown();
    }
}

在上述代码中

  1. 各个线程中的子事务使用编程式事务,可以根据全局事务情况决定是否提交事务
  2. CountDownLatch用于各个线程的同步器,当所有线程子事务执行完之后进行决定是否提交当前事务。
  3. AtomicBoolean用于控制当前全局事务的运行结果,是否要对全部事务进行提交或回滚

3、多线程事务的线程池参数配置的探索

1、线程池死锁

在上述多线程事务实现的方法中,用到线程池去处理事务的执行,但是线程池参数如果设置不当会造成线程池死锁

我们知道,线程池中任务的执行是当有任务时先创建核心线程进行执行任务,如果创建的核心线程数已经等于配置的核心线程数,就将任务加入到阻塞队列,如果阻塞队列也满了就创建非核心线程进行处理。

多线程事务线程池死锁的产生: 假设当前多线程事务有5个线程子事务,线程池的核心线程数配置为4

如图,当有一次多线程事务执行时,先创建4个核心线程执行4个子事务,第5个子事务进入阻塞队列,前4个核心线程的子事务操作完数据库操作后,执行latch.await()等待所有子事务执行完,而第5个子事务又在等核心线程执行完才能执行到自己,这种情况下就造成了线程池的死锁,该线程池一旦死锁,会导致整个多线程事务的业务不可用,影响很大。

那么是不是将对应的核心线程数调大就行了,再来看另一种情况

将核心线程数调大一倍为8,同样一个多线程事务包括5个子事务;假如这时出现了3个并发请求,3个全局多线程事务的子事务分布在如图所示,同样也会造成线程池死锁的问题。

那么将核心线程数调大到100?200?

只调大线程核心参数已经不是最优解了,如果只是将核心线程数调大,核心线程数应该调大到多少呢,过大的话会在突发流量时可以应对,一旦恢复到正常流量又是资源浪费;另外,spring在开启事务时会先获取数据库连接,并且把获取到的数据库连接绑定到当前线程上,如果一直开线程,不去释放相应资源,数据库连接资源也会耗尽,如果处理不当同样会造成线程池死锁(为什么事务的开启要放在try catch中),数据库连接一旦获取失败,整个事务也应该回滚

2、分析

根据线程池处理任务的顺序,会优先创建核心线程处理任务,在不做任何改动只增加核心线程数的前提下,绝对安全并发量=核心线程数/子事务数量,假设,子事务数量为5,核心线程数为10,那么并发量在小于等于2的情况下是绝对安全的,一旦阻塞队列中有了任务,虽然不一定会导致线程池死锁,但说明这时的并发量已经大于了2,要及时处理队列中的任务;如果阻塞队列里的任务不能立即得到处理,就可能发生latch.wait()和阻塞队列中的任务相互等待,造成死锁,所以问题的根本在于阻塞队列中不能有任务,一旦有任务就立即处理

3、解决方案

动态调整线程池参数、选择合适的拒绝策略

动态调整线程池参数: 设置监控程序,一旦发现线程池的阻塞队列中有任务,就立即创建线程执行任务,通过 setMaximumPoolSize()setCorePoolSize()方法动态调整线程池参数;

示例代码,仅供参考

java 复制代码
public void ExpansionMonitor() {
    // 当前线程数
    int poolSize = executor.getPoolSize();
    // 正在工作的线程数
    int activeCount = executor.getActiveCount();
    // 阻塞队列已用数量
    int size = executor.getThreadPoolExecutor().getQueue().size();
    if (size > 0) {
       System.out.println("threadPool 线程池扩容前:" + poolSize + "," + activeCount + "," + size);
       // 扩容最大线程数超过200 设置为200 如果已经超过200,说明业务场景不能用多线程事务,换方案
       if (executor.getMaxPoolSize() + size >= MAX_POOL_SIZE) {
          executor.setCorePoolSize(MAX_POOL_SIZE);
          executor.setMaxPoolSize(MAX_POOL_SIZE);
       } else {
          // 扩容线程数=当前线程数+阻塞队列已用数量  确保队列中不存数据
          executor.setMaxPoolSize(executor.getMaxPoolSize() + size);
          executor.setCorePoolSize(executor.getCorePoolSize() + size);
       }
       System.out.println("threadPool 线程池扩容后:" + executor.getPoolSize() + "," + executor.getActiveCount() + "," + executor.getThreadPoolExecutor().getQueue().size());
    }
    if (poolSize <= MIN_POOL_SIZE) {
       num = 1;
    } else {
       // 如果10s数量没变化 进行缩容
       if (num == 10) {
          System.out.println("threadPool 线程池缩容前:" + executor.getPoolSize() + "," + executor.getActiveCount() + "," + executor.getThreadPoolExecutor().getQueue().size());
          if (executor.getMaxPoolSize() >> 1 < MIN_POOL_SIZE) {
             executor.setCorePoolSize(MIN_POOL_SIZE);
             executor.setMaxPoolSize(MIN_POOL_SIZE);
          } else {
             executor.setCorePoolSize(executor.getPoolSize() >> 1);
             executor.setMaxPoolSize(executor.getPoolSize() >> 1);
          }
          System.out.println("threadPool 线程池缩容后:" + executor.getPoolSize() + "," + executor.getActiveCount() + "," + executor.getThreadPoolExecutor().getQueue().size());
          num = 1;
       }
       num++;
    }
}

灵感来源:Java线程池实现原理及其在美团业务中的实践

拒绝策略:在线程池执行任务的顺序中,如果当前线程数已经等于核心线程数并且阻塞队列已满时就会执行拒绝策略;可以自定义拒绝策略,阻塞队列已满时,继续添加,直到添加成功(会阻塞主线程,理论上讲CallerRunsPolicy也可行)

4、总结

多线程事务并不适用于高并发场景(可能导致线程池死锁;数据库连接数限制),适用于低小流量但业务稍微复杂的场景

若有错误,还望批评指正

相关推荐
小灰灰__2 分钟前
IDEA加载通义灵码插件及使用指南
java·ide·intellij-idea
夜雨翦春韭6 分钟前
Java中的动态代理
java·开发语言·aop·动态代理
程序媛小果26 分钟前
基于java+SpringBoot+Vue的宠物咖啡馆平台设计与实现
java·vue.js·spring boot
追风林32 分钟前
mac m1 docker本地部署canal 监听mysql的binglog日志
java·docker·mac
芒果披萨1 小时前
El表达式和JSTL
java·el
许野平1 小时前
Rust: 利用 chrono 库实现日期和字符串互相转换
开发语言·后端·rust·字符串·转换·日期·chrono
duration~1 小时前
Maven随笔
java·maven
zmgst2 小时前
canal1.1.7使用canal-adapter进行mysql同步数据
java·数据库·mysql
跃ZHD2 小时前
前后端分离,Jackson,Long精度丢失
java
blammmp2 小时前
Java:数据结构-枚举
java·开发语言·数据结构