尧图精选

记一次mybatis-plus自动填充失效暴露出的ThreadLocal与线程池问题:TaoToken统一Key下的排查复盘

🕒 发布时间:2026/10/1 22:20:14 📁 来源:尧图网络
1. 异步线程池里自动填充失效一次 mybatis-plus 多线程 ThreadLocal 排查复盘线上有个接口用户提交数据后create_by、update_by这两个字段偶尔会变成默认值system而不是当前登录用户名。这个接口本身是异步执行的用了Async加自定义线程池。单测、本地调试都正常测试环境跑几十次才复现一次典型的偶发问题。如果你也在用 mybatis-plus 的MetaObjectHandler做公共字段自动填充同时业务里有异步链路那这篇排查过程大概率能帮你省几个小时。核心问题就一句话ThreadLocal 存了登录用户信息但异步线程池复用线程时子线程拿不到、或者拿到了上一个任务残留的值。下面我把从定位到修复的完整过程拆开讲包括可复制的线程池配置、ThreadLocal 传递方案、验证步骤以及怎么用 TaoToken 统一 Key 管理调用凭证避免凭证散落在各个异步任务里。先说结论方便你对号入座现象自动填充字段偶发为空或为默认值非必现根因ThreadLocal不跨线程换成InheritableThreadLocal后线程池复用又会串数据修复用TransmittableThreadLocal TTL 线程池包装或手动透传上下文验证构造线程池复用场景连续提交任务观察填充值是否稳定我试过只换InheritableThreadLocal第一次测试确实好了但压测一上量又出问题所以别停在第一步。2. TaoToken 前置准备统一 Key 与 API 通道让异步链路凭证不再乱排查过程中我发现一个附带问题异步任务里除了用户信息还会调用外部模型接口做内容处理凭证是硬编码在各个Async方法里的。线程池一复用凭证和用户上下文混在一起排查难度翻倍。所以这次顺手把调用凭证统一收口到 TaoToken。TaoToken 是一个统一的大模型 API 接入通道你可以把它理解成一个 Key 管多个模型调用。它适合谁适合像我这样在 Spring Boot 项目里既要调模型、又不想把各家 Key 散落在配置文件各处的后端开发。它能做什么统一 Base URL、统一 Key、统一计费入口切换模型只改 Model ID。官网地址https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI 地址注意不带 UTMhttps://taotoken.net/api接入前你需要准备三件套这三样在任何异步任务里都要能拿到配置项值说明Base URLhttps://taotoken.net/api所有请求走这个入口API Key在控制台生成建议放环境变量别硬编码Model ID按需选择例如对话类、编码类模型生成 Key 的入口在控制台路径是 console 下的 api-keys 页面。我建议你把 Key 放到application.yml里通过环境变量注入而不是写死在异步方法中否则线程池复用时会和用户上下文一样出现串号风险。# application.yml taotoken: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} model-id: your-model-id然后在异步任务里通过注入的配置读取而不是从 ThreadLocal 里取。这一点很关键用户上下文用 ThreadLocal 传递系统级凭证用配置注入两者不要混在一个 ThreadLocal Map 里否则排查时你会分不清是上下文丢了还是凭证串了。如果你需要长期跑编码类或 Agent 类任务可以考虑 Coding Plan它更适合高频、长时间的调用场景。模型对话入口可以用来快速验证 Key 是否可用接入文档里有完整的参数说明。3. 可复制配置线程池 TransmittableThreadLocal 完整方案这一节是重点直接给可复制的代码。先说清楚为什么InheritableThreadLocal不够线程池的核心线程创建后会被复用InheritableThreadLocal只在创建新线程时从父线程拷贝一次。当核心线程空闲被复用它不会重新拷贝于是拿到的是上一个任务留下的值。这就是偶发的来源——核心线程没满时新建线程正常核心线程满了开始复用就出问题。解决方案用阿里开源的transmittable-thread-local版本 2.14.2。dependency groupIdcom.alibaba/groupId artifactIdtransmittable-thread-local/artifactId version2.14.2/version /dependency第一步把用户上下文容器从ThreadLocal换成TransmittableThreadLocalpublic final class UserContextHolder { private static final TransmittableThreadLocalMapString, String CONTEXT new TransmittableThreadLocal(); private UserContextHolder() { } public static void set(MapString, String userInfo) { CONTEXT.set(userInfo); } public static MapString, String get() { return CONTEXT.get(); } public static String getUsername() { MapString, String map CONTEXT.get(); return map null ? null : map.get(username); } public static void clear() { CONTEXT.remove(); } }第二步线程池必须用 TTL 包装否则TransmittableThreadLocal不会自动传递。这是最容易漏的一步很多人只换了容器没包装线程池结果还是失效。Configuration public class AsyncConfig { Bean(bizExecutor) public Executor bizExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setThreadNamePrefix(biz-async-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); // 关键用 TtlExecutors 包装实现上下文透传 return TtlExecutors.getTtlExecutor(executor); } }第三步在异步方法上指定线程池并在主线程设置上下文Async(bizExecutor) public void handleAsync(Long orderId) { String username UserContextHolder.getUsername(); // 这里能稳定拿到主线程设置的用户名 orderService.fillAndSave(orderId, username); }主线程调用前设置UserContextHolder.set(userInfoMap); try { asyncService.handleAsync(orderId); } finally { UserContextHolder.clear(); }注意finally里的clear()虽然 TTL 会做清理但主线程是复用的比如 Tomcat 线程不清理会污染下一个请求。这一步和线程池复用是同一个道理别省。如果你用的是Async默认线程池而不是自定义的那 TTL 包装不会生效必须显式指定Async(bizExecutor)。这一点我在排查时踩过默认线程池不受 TTL 管理。4. 验证请求与成功结果构造复用场景确认填充稳定配置改完不能只看单次成功要构造核心线程复用的场景来验证。思路很简单把核心线程数调小比如设成 2然后连续提交 10 个任务每个任务设置不同的用户名观察自动填充结果是否和提交时一致。写个测试接口RestController public class VerifyController { Autowired Qualifier(bizExecutor) private Executor executor; Autowired private OrderService orderService; GetMapping(/verify/fill) public String verify() { for (int i 1; i 10; i) { final int idx i; MapString, String user new HashMap(); user.put(username, user- idx); UserContextHolder.set(user); executor.execute(() - { String name UserContextHolder.getUsername(); orderService.saveWithFill(order- idx, name); System.out.println(任务 idx 填充用户名: name); }); UserContextHolder.clear(); } return submitted; } }预期结果控制台输出user-1到user-10一一对应数据库里create_by字段也是对应的用户名不会出现user-3的任务填了user-1的值。修复前你会看到类似这样的错乱输出任务 1 填充用户名: user-1 任务 2 填充用户名: user-2 任务 3 填充用户名: user-1 - 线程复用拿到旧值 任务 4 填充用户名: user-4 任务 5 填充用户名: user-2 - 又串了修复后应该是严格对应的。如果还有串值检查三件事线程池是否被 TTL 包装、Async是否指定了该线程池、主线程是否在提交前set且提交后clear。验证模型调用凭证是否统一生效可以用模型对话入口发一条测试请求确认 Base URL 和 Key 配置正确。这一步和用户上下文验证是分开的别混在一起测。5. 本篇常见错排查401、local proxy failed、reading choices 与 OAuth排查过程中我遇到过几类报错这里对照真实信息给你定位思路。401 UnauthorizedTaoToken 的 Key 没配或配错。检查TAOTOKEN_API_KEY环境变量是否注入成功异步线程里读配置是否拿到了值。注意异步线程读Value是没问题的但如果你的 Key 存在 ThreadLocal 里那就会和用户上下文一样丢失。所以凭证走配置不走 ThreadLocal。local proxy failed这类报错通常出现在本地网络环境或代理配置上。检查你的 HTTP 客户端是否误设了代理或者 Base URL 是否写成了带路径的地址。正确写法是https://taotoken.net/api不要多加斜杠或路径。reading choices 相关报错一般是响应体解析失败常见于 Model ID 写错或返回结构不符合预期。确认 Model ID 和接入文档一致别自己拼。异步任务里如果用了不同的 HTTP 客户端注意超时设置线程池任务超时会被中断导致解析到一半失败。OAuth 相关报错如果你在异步链路里做鉴权注意 OAuth token 也有过期时间线程池复用不会帮你刷新 token。建议在任务执行前检查 token 有效性或者用统一的凭证管理通道。还有一个隐蔽的坑Async方法如果和调用方在同一个类里Spring 的代理不生效异步会变成同步执行。这时候 ThreadLocal 反而正常了但你的异步根本没生效。检查方式是把Async方法放到独立的 Service 类里。对照表报错可能原因处理401Key 未注入或错误检查环境变量与配置读取local proxy failed代理误设或 URL 错误核对 Base URL去掉多余路径reading choicesModel ID 错或响应解析失败核对 Model ID 与超时设置OAuth 失效token 过期未刷新任务前校验凭证有效性排障时优先看 API Keys 和接入文档这两个入口能覆盖大部分配置类问题。6. 把凭证与上下文分开管理TaoToken 统一 Key 的落地建议回到这次排查的本质用户上下文和系统凭证是两类东西生命周期和传递方式完全不同。用户上下文跟着请求走用TransmittableThreadLocal跨线程传递系统凭证跟着应用走用配置注入全局一份。我现在的做法是所有异步任务里调模型接口统一走 TaoToken 的 Base URL 和 KeyModel ID 按任务类型区分。这样线程池复用的时候凭证不会串用户上下文由 TTL 保证正确传递两者互不干扰。如果你也在做类似的异步链路改造建议按这个顺序落地先把ThreadLocal换成TransmittableThreadLocal再用TtlExecutors包装线程池然后构造复用场景验证最后把模型调用凭证收口到统一通道。每一步都能独立验证出问题好定位。长期跑编码或 Agent 任务的话Coding Plan 比按次调用更省心需要快速验证模型是否可用直接用模型对话入口发一条消息就行。凭证管理这块控制台的 api-keys 页面可以随时轮换 Key接入文档里有完整的参数和示例。最后留一个实用技巧在MetaObjectHandler里加一行日志打印当前线程名和取到的用户名。线程名带biz-async-前缀一眼就能看出是哪个线程池的任务排查串值时特别有用。Override public void insertFill(MetaObject metaObject) { String username UserContextHolder.getUsername(); log.info(自动填充 insert, thread{}, username{}, Thread.currentThread().getName(), username); this.strictInsertFill(metaObject, createBy, String.class, username null ? system : username); }这行日志在复现偶发问题时比断点还快。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →