1. 项目背景与问题定位
最近在维护一个遗留系统时,遇到了一个棘手的排序问题。系统中有段标记为"41号"的旧代码,负责处理商品列表的排序逻辑。这段代码已经稳定运行了3年多,但最近突然开始出现排序错乱的情况——某些特定条件下,价格从低到高的排序会变成乱序,导致前端展示出现严重问题。
通过日志分析发现,问题出现在促销季流量高峰时段。当并发请求量超过500QPS时,约有15%的请求返回的排序结果异常。更诡异的是,这个问题无法在测试环境复现,只有在生产环境的特定负载下才会显现。
2. 问题诊断过程
2.1 初步代码审查
首先对41号排序代码进行了逐行审查。这段代码使用Java编写,核心是一个Comparator实现:
java复制public class PriceComparator implements Comparator<Product> {
@Override
public int compare(Product p1, Product p2) {
return Double.compare(p1.getPrice(), p2.getPrice());
}
}
表面上看逻辑非常简单直接,似乎不可能出错。但深入分析调用栈后发现三个关键点:
- getPrice()方法内部有动态计算逻辑,会考虑会员折扣、促销活动等
- 排序操作发生在多线程环境下
- 产品列表会被多个模块共享和修改
2.2 并发问题复现
通过压力测试工具模拟高并发场景,终于捕捉到问题现象。发现当两个线程同时执行时:
- 线程A读取p1.price=100
- 线程B修改p1.price=90
- 线程A读取p2.price=95
- 排序结果就会产生不一致
这解释了为什么测试环境无法复现——测试环境的并发量不够,无法触发这种竞态条件。
3. 解决方案设计
3.1 方案选型对比
考虑过三种解决方案:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 同步锁 | 对整个排序过程加synchronized | 实现简单 | 性能瓶颈,吞吐量下降40% |
| 防御性拷贝 | 排序前深拷贝整个列表 | 线程安全 | 内存消耗大,大列表时GC压力大 |
| 不可变数据 | 构建不可变Product视图 | 最佳并发性能 | 需要修改多处调用代码 |
最终选择第三种方案,因为:
- 系统即将进行架构升级,不可变模型更符合未来方向
- 内存消耗比防御性拷贝减少60%
- 虽然改动范围大,但能从根本上解决问题
3.2 具体实现
创建ImmutableProduct类:
java复制public class ImmutableProduct {
private final double price;
public ImmutableProduct(Product p) {
this.price = p.getFinalPrice(); // 快照最终价格
}
public double getPrice() {
return price; // 永远返回构造时的快照值
}
}
修改排序逻辑:
java复制List<ImmutableProduct> immutableList = originalList.stream()
.map(ImmutableProduct::new)
.collect(Collectors.toList());
immutableList.sort(Comparator.comparingDouble(ImmutableProduct::getPrice));
4. 实施与验证
4.1 分阶段上线策略
为了降低风险,采用分阶段上线:
- 先在1%的流量开启新逻辑,对比新旧结果
- 然后逐步提升到5%、20%、50%
- 全量前进行48小时稳定性观察
- 保留快速回滚方案
4.2 监控指标设计
新增了三个监控维度:
- 排序正确性校验(通过采样对比)
- 内存消耗变化
- 排序耗时百分位统计(P99、P999)
5. 经验总结与延伸思考
5.1 关键教训
- 隐式假设的危险性:原代码隐式假设Product对象在排序过程中不会变
- 测试环境的局限性:并发问题往往需要生产级流量才能暴露
- 技术债的代价:简单的Comparator埋下了3年后才爆发的隐患
5.2 最佳实践建议
对于类似排序场景,建议:
- 明确文档记录数据可变性假设
- 对关键Comparator添加并发安全注释
- 压力测试要模拟真实流量模式
- 考虑使用不可变数据快照
这个案例让我深刻认识到,即使是看似简单的排序逻辑,在复杂的生产环境中也可能表现出意想不到的行为。解决问题的关键不在于快速打补丁,而是要深入理解问题本质,找到最符合系统长期演进的解决方案。