支付配置时好时坏?异步方法里的对象引用坑

上周接到客服反馈。

有些商户开通之后,微信支付功能不能用,有些又可以。不能用的商户就需要客服手动介入,重新配置支付参数才恢复正常。

这个问题就很诡异,时好时坏。有时候正常,有时候就不行。

但是后台日志显示都是配置成功。代码执行完了,没有任何报错。

诡异的现象

开始排查日志。发现了几个很奇怪的点:

  1. 业务日志显示成功,没有任何异常
  2. 调用日志显示两次配置用的appid一样
  3. 代码明明写的是两个不同的appid
  4. 配置文件也没问题,两个appid都配对了

代码写的明明是A和B两个appid,为啥日志里都变成B了?

看了好几遍日志,确定没看错。两次调用传入的appid确实一模一样。

那就只能看看代码了。

ini 复制代码
openMemberV2Service.wxConfig(openMemberV2DTO);
openMemberV2DTO.setSupAppId(sysConfig.getWxHuikeOfficialAccountAppid());
openMemberV2DTO.setAccountType("00");
openMemberV2Service.wxConfig(openMemberV2DTO);
LogUtil.info(log, "editGrant >> 开通成功 >> param = {}", param);

乍一看,完全没问题。

第一次调用用的是openMemberV2DTO原始的appid。第二次调用之前,明明修改了supAppIdaccountType

代码逻辑也很清晰,为啥会出问题?

会不会是配置文件把appid配错了?

检查了配置文件:

配置也没问题。两个appid都对。

既然代码逻辑没问题,配置也没问题,那问题大概率就是wxConfig这个方法了

点开wxConfig方法:

typescript 复制代码
public void wxConfig(OpenMemberV2DTO dto) {
    asyncTaskExecutor.execute(() -> {
        // 获取参数
        String subAppId = dto.getSupAppId();
        String accountType = dto.getAccountType();

        // 调用微信API配置
        JSONObject body = new JSONObject();
        body.put("sub_appid", subAppId);
        body.put("account_type", accountType);

        // 调用平台接口...
        platformManager.call(callParam);
    });
}

看到asyncTaskExecutor.execute,相信大家就明白了。

这是个异步方法!

那问题链路就清楚了:

  1. 主线程调用wxConfig(openMemberV2DTO) → 任务提交到线程池,还没执行
  2. 主线程继续执行,修改openMemberV2DTO.setSupAppId(...)
  3. 主线程再次调用wxConfig(openMemberV2DTO) → 又提交一个任务到线程池
  4. 线程池开始执行第一个任务 → 读取dto.getSupAppId(),发现已经是修改后的值了
  5. 线程池执行第二个任务 → 还是同一个对象,appid当然一样

两次传的是同一个对象引用,第二次修改污染了第一次的数据。

问题本质

这个问题的本质是:对象引用 + 异步方法 ≈ 数据污染

Java对象引用机制

Java方法传参,传的是引用,不是副本。

scss 复制代码
// 这两次调用,传的是同一个对象的引用
wxConfig(openMemberV2DTO);  // 传的是引用
openMemberV2DTO.setSupAppId("new_appid");  // 修改对象
wxConfig(openMemberV2DTO);  // 还是同一个引用

如果wxConfig是同步方法,没问题。因为第一次调用会立即执行完,用的是修改前的值。

wxConfig是异步方法。

异步方法的执行时机

异步方法不会立即执行,而是提交到线程池,等线程池有空闲线程时才执行。

scss 复制代码
// 主线程时间线
t1: wxConfig(dto)  → 提交任务A到线程池
t2: dto.setSupAppId("new")  → 修改对象
t3: wxConfig(dto)  → 提交任务B到线程池
t4: 主线程结束

// 线程池时间线
t5: 执行任务A → 读取dto.getSupAppId()  // 已经是"new"了!
t6: 执行任务B → 读取dto.getSupAppId()  // 还是"new"

完整的问题链路

sequenceDiagram participant Main as 主线程 participant Pool as 线程池 participant Obj as openMemberV2DTO对象 Note over Main: t1时刻 Main->>Pool: wxConfig(openMemberV2DTO) Note over Pool: 任务A进入队列
等待执行 Note over Main: t2时刻 Main->>Obj: setSupAppId("wx_huike...") Main->>Obj: setAccountType("00") Note over Obj: 对象状态被修改 Note over Main: t3时刻 Main->>Pool: wxConfig(openMemberV2DTO) Note over Pool: 任务B进入队列
等待执行 Note over Main: t4时刻 Note over Main: 主线程结束
打印"开通成功" Note over Pool: t5时刻 Pool->>Obj: 执行任务A
读取getSupAppId() Note over Obj: 返回:"wx_huike..."
已经是修改后的值! Note over Pool: t6时刻 Pool->>Obj: 执行任务B
读取getSupAppId() Note over Obj: 返回:"wx_huike..."
还是同一个值!

两个任务读取的都是修改后的值。

为什么这个bug特别容易被忽略。?

代码看起来完全正常

scss 复制代码
wxConfig(dto);  // 第一次调用
dto.setSupAppId("new");  // 修改
wxConfig(dto);  // 第二次调用

如果不知道wxConfig是异步的,这代码一点问题都没有。

没有啥报错

业务逻辑正常执行完了,日志显示成功。不会抛异常,不会空指针。

只是配置错了而已。

问题是偶发的

这才是最隐蔽的地方。

如果线程池刚好空闲,第一个任务提交后立即执行完了,第二个任务再进来,就不会有问题。

但如果线程池正忙,两个任务都在队列里等待,等到真正执行时,对象可能早就被修改了。

时好时坏,最难排查。

日志的迷惑性

csharp 复制代码
[INFO] 开通成功

这行日志在主线程结束时打印,让人以为一切正常。

实际上,线程池里的任务才刚开始执行。

Code Review容易被遗漏

Code Review时,如果不进入wxConfig方法内部看,很难发现是异步的。

而且代码逻辑确实没问题,就是少考虑了异步场景。

解决方案

创建新对象

最简单的办法:给第二次调用创建一个新对象。

ini 复制代码
openMemberV2Service.wxConfig(openMemberV2DTO);
// 创建新对象
OpenMemberV2DTO openMemberDTO = BeanUtil.map(openMemberV2DTO, OpenMemberV2DTO.class);
openMemberDTO.setSupAppId(sysConfig.getWxHuikeOfficialAccountAppid());
openMemberDTO.setAccountType("00");
openMemberV2Service.wxConfig(openMemberDTO);

BeanUtil.map会创建一个新对象,复制所有属性。

两次调用传入的是不同的对象引用,互不影响。

其他方案

方案1:深拷贝

ini 复制代码
OpenMemberV2DTO newDto = openMemberV2DTO.clone();

前提是DTO实现了Cloneable接口,并且是深拷贝。

方案2:不可变对象

scss 复制代码
// 创建新对象,而不是修改旧对象
OpenMemberV2DTO dto2 = OpenMemberV2DTO.builder()
    .supAppId(sysConfig.getWxHuikeOfficialAccountAppid())
    .accountType("00")
    .build();

wxConfig(dto2);

方案3:异步方法内部拷贝(最推荐)

arduino 复制代码
public void wxConfig(OpenMemberV2DTO dto) {
    // 进入方法就立即拷贝参数
    OpenMemberV2DTO copyDto = FsBeanUtil.map(dto, OpenMemberV2DTO.class);

    asyncTaskExecutor.execute(() -> {
        // 使用拷贝的对象
        String subAppId = copyDto.getSupAppId();
        // ...
    });
}

为什么推荐这个方案?

  1. 调用方不用关心是否异步:封装性好,对外接口简洁
  2. 一劳永逸:修改一次,所有调用方都受益
  3. 防御性编程:在方法内部做好防护,不依赖调用方的正确使用

这次修复后,我就是用的这个方案。把拷贝逻辑加到wxConfig方法内部,之后就再也没出过问题。

总结

这次事故让我学到了几点。

1. 异步方法要警惕对象引用

只要方法是异步的,传入的对象可能在任何时候被修改。

不要假设"调用方不会改"。要么拷贝对象,要么用不可变对象。

2. Code Review不只看逻辑

Review代码时,不能只看表面逻辑是否正确。

还要关注:

  • 方法是同步还是异步?
  • 对象是共享的还是独立的?
  • 有没有线程安全问题?

3. 线程安全不只是加锁

很多人提到线程安全,第一反应是加锁。

但这个问题加锁也没用,根本原因是对象被共享了

线程安全的本质:避免共享可变状态

4. 日志要打印关键参数

如果一开始没有打印appid,这个问题会更难排查。

c 复制代码
// 好的日志
LogUtil.info(log, "wxConfig >> sub_appid = {}, account_type = {}",
    dto.getSupAppId(), dto.getAccountType());

// 不够的日志
LogUtil.info(log, "wxConfig >> 开始配置");

关键参数一定要打印出来。

5. 排查问题要看方法实现

这次能快速定位,是因为进入了wxConfig方法内部,看到了asyncTaskExecutor.execute

如果只看调用代码,永远找不到原因。

写在最后

异步方法传参时,注意对象引用问题。如果需要多次调用并修改参数,务必创建新对象。

代码很简单,但容易忽略。大家一起共勉。

相关推荐
资生算法程序员_畅想家_剑魔2 小时前
Java常见技术分享-14-多线程安全-锁机制-常见的锁以及底层实现-synchronized
java·开发语言
JoStudio2 小时前
白帽系列01: 抓包
java·网络安全
sanggou2 小时前
基于Java实现的简易规则引擎(日常开发难点记录)
android·java
先做个垃圾出来………2 小时前
Python测试桩工具
java·开发语言·python
小芳矶2 小时前
【langchain框架——检索链】利用检索链创建自己的购物知识库并完成智能体的商品推荐
java·python·langchain
爱吃山竹的大肚肚2 小时前
优化SQL:如何使用 EXPLAIN
java·数据库·spring boot·sql·spring
行思理2 小时前
FastAdmin新手教程
java·开发语言·fastadmin
向上的车轮2 小时前
Apache Camel 与 Spring Integration的区别是什么?
java·spring·apache
韭菜炒大葱2 小时前
TailwindCSS:从“样式民工”到“UI乐高大师”的逆袭
前端·面试·编程语言