【Spring Boot 老中医】Spring Boot的事务注解@Transactional中使用工作线程

1,553 阅读5分钟

摘要:

@Transactional中使用工作线程需要解决三大问题:

  1. @Transactional 不支持多线程;
  2. @Transactional 中的工作线程无法获取主线程修改;
  3. @Transactional 中的工作线程会上下文丢失。 本文使用AOP+Spring事件给出解决这三大问题的一种实践。

事务注解 @Transactional

总做周知,在Spring Boot中可以很方便的使用事务,在函数或类上使用注解@Transactional即可。 另外一方面,对于高耗时和涉及网络IO的操作,为了不阻塞主线程,一般会使用工作线程处理高耗时和网络IO。

考虑下面一个业务场景: web服务收到人员信息更新请求updatePerson,主要有下面两个业务操作

@Transactional
public void updatePerson(String personCode) {
   // 1. 将新的人员信息更新的数据库
   ...
   // 2. 将新的人员信息同步到Elasticsearch
   esService.syncToEs(personCode);
}

其中,第一步只会更新数据库表t_person。而第二步同步到Elasticsearch是一个耗时的操作,它会读取多个表格,t_persont_companyt_social_relation,然后整合成一个对象发送给ES建立索引。

现在为了支持实时更新功能,需要优化updatePerson函数的执行时间。那么需要将esService.syncToEs(personCode);放到工作线程中执行。

但是,这个操作面临三大问题:

  1. @Transactional 不支持多线程
  2. @Transactional 中的工作线程无法获取主线程修改
  3. @Transactional 中的工作线程会上下文丢失

@Transactional 中开启工作线程面临的问题

1. @Transactional 不支持多线程

Spring Boot 对@Transactional的说明是

This annotation commonly works with thread-bound transactions managed by org.springframework.transaction.PlatformTransactionManager, exposing a transaction to all data access operations within the current execution thread. Note: This does NOT propagate to newly started threads within the method.

也就是说@Transactional是thread-bound(线程绑定),只在当前线程有效。对工作线程中的操作,@Transactional并不做任何事务的保证(一致性,隔离性,原子性,持久性)。

那么,有聪明的小伙伴就会回答,在工作线程中执行esService.syncToEs(personCode);完全可以开一个新的事务,姑且称它为工作线程事务。人员信息更新到数据库和同步es是可以拆分成两个动作的,主线程事务和工作线程事务是两个独立的事务。

但是,事情没有那么简单。这会面临第二个问题:工作线程无法获取主线程修改。

2. @Transactional 中的工作线程无法获取主线程修改

由于工作线程是在主线程事务运行过程中开启,也就是说,主线程事务还未提交,工作线程事务已经开启。那么,工作线程中的事务是无法看到主线程事务的更改(假设数据库隔离级别在提交读及以上)。

因此,需要在主线程事务结束后才能开始工作线程事务。例如下面的改造

public void updatePerson(String personCode) {
   // 1. 将新的人员信息更新的数据库
   updatePersonMainProcess(persoCode);
   // 2. 将新的人员信息同步到Elasticsearch
   esService.syncToEs(personCode);
}

@Transactional
private void updatePersonMainProcess(String personCode) {
   // 1. 将新的人员信息更新的数据库
   ...
}

public class EsService {
    @Async
    @Transactional
    public void syncToEs(String personCode) {
       // 2. 将新的人员信息同步到Elasticsearch
       ...
    }
}

然而还有第三个问题:@Transactional 中的工作线程会上下文丢失

3. @Transactional 中的工作线程会上下文丢失

在Spring Boot 中有上下文,例如web请求的上下文RequestContextHolder,它的内部是使用了ThreadLocal来实现的,因此RequestContextHolder也是thread-bound。当开启工作线程时,会导致web请求的上下文丢失。

当然,RequestContextHolder也提供了可以子线程可继承的setRequestAttributes(@Nullable RequestAttributes attributes, boolean inheritable)方法。在开启工作线程前,将已有的属性重新设置一遍.

RequestContextHolder.setRequestAttributes(currentRequestAttributes(), true)

但是,在线程池+RequestContextHolder.setRequestAttributes(@Nullable RequestAttributes attributes, boolean inheritable)是无效的。

原因是RequestContextHolder的父子线程继承上下文是通过InheritableThreadLocal实现。但是,InheritableThreadLocal无法在线程池的线程中工作。

继续深究下去,是因为InheritableThreadLocal在子线程创建(实现时会延迟到get方法)时将父线程的上下文拷贝过来。而线程池中的线程是复用的(并不会重新创建线程),因此无法将父线程上下文拷贝过来。

解决方案

可以使用阿里巴巴开发的transmittable-thread-localGithub地址 中的TransmittableThreadLocal来解决线程池下父子线程上下文传递的问题。

简易介绍:使用类TransmittableThreadLocal来保存值,并跨线程池传递。

示例代码:

TransmittableThreadLocal<String> context = new TransmittableThreadLocal<>();

// =====================================================

// 在父线程中设置
context.set("value-set-in-parent");

// =====================================================

// 在子线程中可以读取,值是"value-set-in-parent"
String value = context.get();

@Transactional 中优雅开启工作线程

总结@Transactional 中开启工作线程需要增加的操作

  1. 工作线程需要在主线程事务结束后才开始运行
  2. 需要把主线程的上下文传递到工作线程中

下面给出一个实践,利用AOP+Spring事件来开启工作线程。

  • 实现一个Spring事件发送类,在被调用publish发送事件时,不直接发送事件,而是将事件存在上下文中。在控制器Controller执行成功返回后,取出保存在上下文的事件,开启工作线程发送事件。

Spring Boot的事务注解@Transactional中使用工作线程.png

工作逻辑:

  1. PersonService中不直接执行同步es,而是使用AfterRequestEventPublisher抛出人员信息变更事件
  2. AfterRequestEventPublisher实现了ApplicationEventPublisher接口,其publish方法重写为:把事件保存到上下文中
  3. AfterRequestEventPublisher做了切面,实现事后返回通知afterReturningAdvice,拦截所有控制器,这里开启工作线程发送事件。当控制器返回时,Spring调用事后返回通知AfterRequestEventPublisher.afterReturningAdvice
public class PersonService {
    // 自己实现的Spring事件通知器,负责在controller返回后启动工作线程发送事件
    @Resource
    private AfterRequestEventPublisher afterRequestEventPublisher;
    @Transactional
    public void updatePerson(String personCode) {
       // 1. 将新的人员信息更新的数据库
       ...
       // 2. 抛出事件,通知将新的人员信息同步到Elasticsearch
       afterRequestEventPublisher.publish(new PersonEditEvent(PersonCode))
    }
}

public class AfterRequestEventPublisher implements ApplicationEventPublisher {
    // Spring boot 默认的事件通知
    @Resource
    private ApplicationEventPublisher publisher;
    // 工作线程的线程池
    @Resource
    private ThreadPoolTaskExecutor threadPoolTaskExecutor;
    /**
     * 将事件收集起来,在请求结束的时候开启工作线程异步发送事件
     */
    @Override
    public void publishEvent(Object event) {
        List<Object> eventObjects = (List<Object>) RequestContextHolder.currentRequestAttributes()
                .getAttribute("AfterRequestEventPublisher", RequestAttributes.SCOPE_REQUEST);
        if (Objects.isNull(eventObjects)) {
            eventObjects = new ArrayList<>();
            eventObjects.add(event);
            RequestContextHolder.currentRequestAttributes()
                    .setAttribute("AfterRequestEventPublisher", eventObjects, RequestAttributes.SCOPE_REQUEST);
        } else {
            eventObjects.add(event);
        }
    }
    
    /**
     * 拦截所有controller控制器的所有方法,在controller结束的时候才抛出事件
     */
    @AfterReturning(pointcut = "execution(public * org.example.controller.*Controller.*(..))", returning = "retVal")
    public void afterReturningAdvice(Object retVal) {
        final List<Object> eventObjects = (List<Object>) RequestContextHolder.currentRequestAttributes()
                .getAttribute("AfterRequestEventPublisher", RequestAttributes.SCOPE_REQUEST);
        if (CollectionUtils.isEmpty(eventObjects)) {
            return;
        }
       // 上下文传递到工作线程,在工作线程中发送事件。这里不使用@Async的原因是留给未来做一些线程的自定义设置。例如用更优雅的上下文传递等。
       // 这里要改成用 TransmittableThreadLocal
        RequestContextHolder.setRequestAttributes(RequestContextHolder.currentRequestAttributes(), true);
        threadPoolTaskExecutor.execute(() -> {
            for (Object event : eventObjects) {
                publisher.publishEvent(event);
            }
        });
    }
}

public class EsService {
    // 接收 PersonEditEvent 事件,在工作线程中执行
    @EventListener(PersonEditEvent.class)
    @Transactional
    public void syncToEs(PersonEditEvent event) {
        // 同步到es中
    }
}

总结

Spring Boot的事务注解@Transactional中使用工作线程的前提条件:

  1. 主线程的流程可以拆分成两个独立的事务。

    如果在本例中,如果同步es操作的逻辑时,只将变更部分的信息同步到es中,那么该操作是不能在工作线程中执行。因为工作线程事务不能感知主线程事务是否提交成功。如果主线程事务回滚,而工作线程依然把变更部分同步到es中,那么就造成了数据库和es不一致。同理,主线程成功提交,而工作线程回滚也会造成数据库与es不一致。

Spring Boot的事务注解@Transactional中使用工作线程会遇到的问题:

  1. @Transactional 不支持多线程;

    要求主线程和工作线程使用两个独立的事务。

  2. @Transactional 中的工作线程无法获取主线程修改;

    要求工作线程在主线程事务结束后才能开始执行。

  3. @Transactional 中的工作线程会上下文丢失。

    要求传递上下文。使用 transmittable-thread-localGithub地址 中的TransmittableThreadLocal来保存值,并跨线程池传递。