上周接到客服反馈。
有些商户开通之后,微信支付功能不能用,有些又可以。不能用的商户就需要客服手动介入,重新配置支付参数才恢复正常。
这个问题就很诡异,时好时坏。有时候正常,有时候就不行。
但是后台日志显示都是配置成功。代码执行完了,没有任何报错。
诡异的现象
开始排查日志。发现了几个很奇怪的点:
- 业务日志显示成功,没有任何异常
- 调用日志显示两次配置用的appid一样
- 代码明明写的是两个不同的appid
- 配置文件也没问题,两个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。第二次调用之前,明明修改了supAppId和accountType。
代码逻辑也很清晰,为啥会出问题?
会不会是配置文件把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,相信大家就明白了。
这是个异步方法!
那问题链路就清楚了:
- 主线程调用
wxConfig(openMemberV2DTO)→ 任务提交到线程池,还没执行 - 主线程继续执行,修改
openMemberV2DTO.setSupAppId(...) - 主线程再次调用
wxConfig(openMemberV2DTO)→ 又提交一个任务到线程池 - 线程池开始执行第一个任务 → 读取
dto.getSupAppId(),发现已经是修改后的值了 - 线程池执行第二个任务 → 还是同一个对象,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"
完整的问题链路
等待执行 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();
// ...
});
}
为什么推荐这个方案?
- 调用方不用关心是否异步:封装性好,对外接口简洁
- 一劳永逸:修改一次,所有调用方都受益
- 防御性编程:在方法内部做好防护,不依赖调用方的正确使用
这次修复后,我就是用的这个方案。把拷贝逻辑加到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。
如果只看调用代码,永远找不到原因。
写在最后
异步方法传参时,注意对象引用问题。如果需要多次调用并修改参数,务必创建新对象。
代码很简单,但容易忽略。大家一起共勉。