一场跨越技术栈的诡异异常侦破记
一场跨越技术栈的诡异异常侦破记
时间:2016年夏天
技术栈:SSM + Dubbo + jQuery EasyUI
患者:电商后台管理系统订单模块
第一幕:平静的表象
那是一个炎热的下午,我刚完成订单导出功能的开发。代码逻辑清晰,本地测试完美:
@Service
public class OrderExportServiceImpl implements OrderExportService {public List<OrderDTO> queryOrdersForExport(OrderQuery query) {// 看似正常的查询逻辑return orderMapper.selectByCondition(query);}public void exportOrders(HttpServletResponse response, OrderQuery query) {List<OrderDTO> orders = queryOrdersForExport(query);// 生成Excel并输出ExcelUtil.export(response, orders, "订单列表");}
}
前端调用也很简单:
function exportOrders() {$('#exportBtn').linkbutton('disable');var queryForm = $('#queryForm').serialize();window.location.href = '/order/export?' + queryForm;
}
第二幕:诡异现象初现
测试环境部署后,测试同事反馈:"导出功能时好时坏,有时候能成功,有时候浏览器直接下载一个0字节的文件"
更诡异的是:
- 没有任何错误日志
- 成功和失败没有明显规律
- 本地开发环境从未复现
第三幕:深入调查
第一次诊断:添加详细日志
@Slf4j
@Service
public class OrderExportServiceImpl implements OrderExportService {public void exportOrders(HttpServletResponse response, OrderQuery query) {log.info("开始导出订单,查询条件:{}", query);try {List<OrderDTO> orders = queryOrdersForExport(query);log.info("查询到{}条订单记录", orders.size());ExcelUtil.export(response, orders, "订单列表");log.info("订单导出完成");} catch (Exception e) {log.error("订单导出异常", e);throw new RuntimeException("导出失败", e);}}
}
结果:日志显示一切正常,查询到数据,执行了导出,但浏览器还是偶尔收到0字节文件。
第二次诊断:怀疑Dubbo超时
考虑到订单查询涉及多个Dubbo服务调用:
public List<OrderDTO> queryOrdersForExport(OrderQuery query) {List<OrderDTO> orders = orderMapper.selectByCondition(query);for (OrderDTO order : orders) {// Dubbo调用用户服务UserDTO user = userService.getUserById(order.getUserId());// Dubbo调用商品服务 ProductDTO product = productService.getProductById(order.getProductId());order.setUserName(user.getName());order.setProductName(product.getName());}return orders;
}
检查Dubbo配置:
<dubbo:service interface="com.xxx.UserService" ref="userService" timeout="5000" retries="2"/>
结果:调整超时时间到10秒,问题依旧。
第四幕:突破性发现
在连续监控了几天后,终于发现了一个规律:当导出数据量较大时(超过1000条),失败概率显著增加
第三次诊断:线程堆栈分析
使用jstack
分析应用线程状态,发现了一个关键线索:
"http-nio-8080-exec-5" #31 daemon prio=5 os_prio=0 tid=0x00007f9b3822c800 nid=0x1e5e waiting on condition [0x00007f9b0f7e6000]java.lang.Thread.State: TIMED_WAITING (parking)at sun.misc.Unsafe.park(Native Method)- parking to wait for <0x00000000f0a8c2c8> (a java.util.concurrent.CompletableFuture$Signaller)at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)at java.util.concurrent.CompletableFuture$Signaller.block(CompletableFuture.java:1695)at java.util.concurrent.ForkJoinPool.managedBlock(ForkJoinPool.java:3323)at java.util.concurrent.CompletableFuture.waitingGet(CompletableFuture.java:1729)at java.util.concurrent.CompletableFuture.get(CompletableFuture.java:1895)at com.xxx.OrderExportServiceImpl.queryOrdersForExport(OrderExportServiceImpl.java:45)
关键发现:线程在等待CompletableFuture.get()
,但代码中根本没有使用CompletableFuture
!
第五幕:真相大白
仔细检查MyBatis的Mapper配置,发现了问题根源:
<!-- 订单Mapper配置 -->
<select id="selectByCondition" parameterType="OrderQuery" resultMap="OrderResultMap" fetchSize="1000" timeout="30">SELECT * FROM orders <where><if test="status != null">AND status = #{status}</if><if test="startTime != null">AND create_time >= #{startTime}</if><if test="endTime != null">AND create_time <= #{endTime}</if></where>ORDER BY create_time DESC
</select>
<!-- 问题所在:这个resultMap被其他查询引用 -->
<resultMap id="OrderResultMap" type="OrderDTO"><id property="id" column="id"/><result property="orderNo" column="order_no"/><!-- 其他字段映射 --><!-- 这个association导致了N+1查询问题 --><association property="user" column="user_id" select="com.xxx.UserMapper.selectById"/><association property="product" column="product_id" select="com.xxx.ProductMapper.selectById"/>
</resultMap>
问题根源:
- MyBatis的
<association>
标签在数据量大时会产生N+1查询问题 - 虽然我们在Service层显式调用了Dubbo服务,但MyBatis仍然会尝试执行关联查询
- 当数据量较大时,大量的数据库连接请求导致某些请求被静默丢弃
- Dubbo的异步调用与MyBatis的懒加载机制产生了微妙的冲突
第六幕:解决方案
方案一:优化MyBatis配置
<!-- 为导出功能专门创建一个resultMap,不使用association -->
<resultMap id="OrderExportResultMap" type="OrderDTO"><id property="id" column="id"/><result property="orderNo" column="order_no"/><result property="userId" column="user_id"/><result property="userName" column="user_name"/><result property="productId" column="product_id"/><result property="productName" column="product_name"/><!-- 其他直接字段 -->
</resultMap>
<select id="selectForExport" parameterType="OrderQuery" resultMap="OrderExportResultMap">SELECT o.*,u.name as user_name,p.name as product_nameFROM orders oLEFT JOIN users u ON o.user_id = u.idLEFT JOIN products p ON o.product_id = p.id<where><if test="status != null">AND o.status = #{status}</if><if test="startTime != null">AND o.create_time >= #{startTime}</if><if test="endTime != null">AND o.create_time <= #{endTime}</if></where>ORDER BY o.create_time DESC
</select>
方案二:重构Service逻辑
@Service
public class OrderExportServiceImpl implements OrderExportService {@Autowiredprivate OrderMapper orderMapper;public void exportOrders(HttpServletResponse response, OrderQuery query) {// 1. 先查询基础数据List<OrderExportVO> orders = orderMapper.selectForExport(query);// 2. 批量获取用户和商品信息(避免循环中的Dubbo调用)Set<Long> userIds = orders.stream().map(OrderExportVO::getUserId).collect(Collectors.toSet());Set<Long> productIds = orders.stream().map(OrderExportVO::getProductId).collect(Collectors.toSet());Map<Long, String> userMap = userService.batchGetUsers(userIds);Map<Long, String> productMap = productService.batchGetProducts(productIds);// 3. 组装数据List<OrderDTO> result = orders.stream().map(order -> {OrderDTO dto = new OrderDTO();// 属性拷贝BeanUtils.copyProperties(order, dto);dto.setUserName(userMap.get(order.getUserId()));dto.setProductName(productMap.get(order.getProductId()));return dto;}).collect(Collectors.toList());// 4. 导出ExcelUtil.export(response, result, "订单列表");}
}
方案三:前端优化
function exportOrders() {var $btn = $('#exportBtn');var originalText = $btn.linkbutton('getText');$btn.linkbutton({disabled: true,text: '导出中...'});// 显示进度提示$.messager.progress({title: '请稍候',msg: '数据导出中...',text: '正在准备数据'});var queryForm = $('#queryForm').serialize();// 使用Ajax先检查数据量$.get('/order/export/check?' + queryForm, function(result) {if (result.data.count > 5000) {$.messager.confirm('提示', '本次导出数据量较大(' + result.data.count + '条),可能需要较长时间,是否继续?', function(r) {if (r) {doExport(queryForm, $btn, originalText);} else {resetButton($btn, originalText);$.messager.progress('close');}});} else {doExport(queryForm, $btn, originalText);}});
}function doExport(queryForm, $btn, originalText) {var iframe = $('<iframe style="display:none"></iframe>');$('body').append(iframe);iframe.on('load', function() {setTimeout(function() {resetButton($btn, originalText);$.messager.progress('close');iframe.remove();}, 1000);});iframe.attr('src', '/order/export?' + queryForm);
}
第七幕:经验总结与思考
技术层面的反思:
- 框架特性理解:深刻理解所用框架的底层机制,特别是像MyBatis这样的ORM框架的懒加载、缓存等特性
- 分布式事务边界:在Dubbo等分布式架构中,要明确事务边界,避免跨服务的复杂事务操作
- 资源管理:数据库连接、HTTP连接等资源要及时释放,特别是在大数据量操作时
工程实践改进:
- 代码审查清单:将"避免N+1查询"、"合理使用异步"等加入代码审查清单
- 监控体系完善:
// 添加性能监控
@Around("execution(* com.xxx..*Service.*(..))")
public Object monitorPerformance(ProceedingJoinPoint joinPoint) throws Throwable {long start = System.currentTimeMillis();try {return joinPoint.proceed();} finally {long cost = System.currentTimeMillis() - start;if (cost > 1000) { // 超过1秒记录警告日志log.warn("方法执行缓慢: {},耗时: {}ms", joinPoint.getSignature(), cost);}}
}
- 分层架构规范:明确各层的职责,避免在数据访问层混入业务逻辑
人生感悟:
这次调试经历让我明白,在软件开发中:
- 最可怕的不是报错,而是不报错
- 巧合背后往往有必然
- 理解原理比掌握API更重要
从那以后,我们团队建立了"诡异问题档案馆",记录和分享各种奇葩问题的解决方案。这个习惯在后续的技术栈迁移到Spring Cloud + Vue时,同样帮助我们解决了很多疑难杂症。
记住,每个诡异的bug都是提升技术深度的机会!💪