业务逻辑编排错误 & TTL浅拷贝导致参数丢失问题

前言

在DDD项目中,为了方便参数的传递,通常会使用ThreadLocal来保存一个对象来实现对参数的跨方法传递,避免通过形参的形式传递。在内部项目中,有一个项目使用的是 alibaba开源的 transmittable-thread-local来存储参数,新建了一个上下文对象(AbilityContext.java),使用HashMap来临时存储和获取参数。

AbilityContext 示例
public class AbilityContext {
    private static final ThreadLocal<Map<String, Object>> CONTEXT = new TransmittableThreadLocal<>();

    private AbilityContext() {
    }

    /**
     * 初始化上下文
     */
    public static void initContext() {
        Map<String, Object> con = CONTEXT.get();
        if (con == null) {
            CONTEXT.set(new HashMap<>(8));
        } else {
            CONTEXT.get().clear();
        }
    }

    /**
     * 清除上下文
     */
    public static void clearContext() {
        CONTEXT.remove();
    }

    public static Map<String, Object> getInnerMap() {
        return CONTEXT.get();
    }

    /**
     * 获取上下文内容
     */
    public static <T> T getValue(String key) {
        Map<String, Object> con = CONTEXT.get();
        if (con == null) {
            return null;
        }
        return (T) con.get(key);
    }

    /**
     * 设置上下文参数
     */
    public static void putValue(String key, Object value) {
        Map<String, Object> con = CONTEXT.get();
        if (con == null) {
            CONTEXT.set(new HashMap<>(8));
            con = CONTEXT.get();
        }
        con.put(key, value);
    }
}

项目情况介绍

通常来说,DDD项目的基本流程是由interface->application,中间封装一层来集中处理上下文的初始化和清空动作,如下图:
ddd

在正常情况下,上述流程可以正确的完成参数的写入和获取,但是,在项目运行过程中遇到了一个bug,正常写入参数后,偶现性(低频)获取值为NULL,导致程序出错,示例代码如下(隐去业务代码,重新写的伪代码):

  1. 其中demo()方法为当时复现的方法
  2. demo2()为伪代码,是业务代码中调用了另一个application,假设其逻辑和demo()方法一致的业务代码。
@Slf4j
public class AlibabaTtlWrongUsageExampleApplication {

    public static void main(String[] args) {
        demo(i);
    }

    private static void demo(int idx) {
        // 初始化
        AbilityContext.initContext();
        // 赋业务值
        AbilityContext.putValue("main", "mainValue");

        // 这里简化了代码,实际上经过了很多层业务代码调用后才出现了此方法
        ThreadUtil.execute(() -> {
            execute->demo2();
        });

        // do something

        // 主线程再次获取业务值(偶现为null)
        String value = AbilityContext.getValue("main");
        if (Objects.isNull(value)) {
            log.warn("lastGetNullValue, idx={}", idx);
        }
    }
}

上述代码运行设置了一个key=main,值为mainValue。在下方AbilityContext.getValue("main")偶现获取==NULL。

展开分析

当时在分析的开始有推测是业务代码中参数被重新赋值为NULL,但通过对后续业务代码逐行查看,并没有找到重新赋值的逻辑。
在深入业务代码分析的过程中,发现主流程中有一个异步方法调用(ThreadUtil.execute()),再次调用了另一个领域服务(这是不符合DDD规范的!),而领域服务的入口都会AbilityContext.initContext()的逻辑,通过这个线索 ,继续展开了深入分析。

ddd

编码者的初衷可能是想到异步线程已经脱离了当前线程,再次调用 initContext()方法是初始化了一个新的对象上下文但是由于项目使用的是 alibaba TTL,能够实现跨线程的传递,所以在子线程中依旧能拿到父线程的HashMap。并且TTL默认是使用的浅拷贝对象。由于initContext()中,调用了HashMap.clear()方法,相当于将父线程的HashMap给清空了!

通过比对父子线程的hashCode值确定为同一对象

// 主线程获取hashCode
final int hashCode = AbilityContext.getInnerMap().hashCode();
ThreadUtil.execute(() -> {
    // 子线程对比hashCode
    log.info("{}, ThreadUtil hashCode={}", idx, AbilityContext.getInnerMap().hashCode() == hashCode);
    // 子线程再次初始化(错误的根源)
    AbilityContext.initContext();
    // do something
});
14:42:28.198 [pool-1-thread-26] INFO top.imyzt.learning.caseanalysis.ttl.AlibabaTtlWrongUsageExampleApplication -- 25, ThreadUtil hashCode=true

持续分析

有了上述的线索,基本把问题原因找到了,但是为什么是偶现的呢
因为使用了异步线程,而线程的调度由操作系统的线程调度算法来决定,并不是一定保证顺序的,所以只要当操作系统优先调度异步线程,那么HashMap就被清空了,如果主线程优先往下走,那么就能够获取到完整的HashMap

后记

至此,问题分析就告一段落了,整个过程中涉及到 TTL值的父子线程传递、对象浅拷贝、线程的调度,还涉及到了DDD的不规范逻辑编排,整个分析下来花费了一上午的时间,收获还是很大的。

  1. transmittable-thread-local
  2. TransmittableThreadLocal的传递只有浅拷贝吗?
  3. 线程的优先级

我将源代码上传了GitHub,如果你想在本地调试运行上述案例,可以下载到本地调试,有问题可以评论区沟通。