1 前言
一年一度的双十一大促如台风过境般席卷而来又呼啸而去,它是对后端开发者过去一年技术沉淀的集中检阅。它用数以亿计的并发请求,无情地放大我们系统中每一个懒惰的循环、每一次不经意的锁竞争、每一个被忽略的慢查询。在平时,这些可能只是监控图上一个不起眼的毛刺;但在洪峰来临的瞬间,它们会像多米诺骨牌一样连锁倒下,最终演变成一场技术灾难。
本文将带你回顾,我们是如何从一次次的压力测试和线上事故中,抽丝剥茧,对系统进行深度性能调优的。这不仅仅是为了扛住那个特定的峰值,更是为了构建一个在任何时候都更加健壮、高效和可靠的系统。让我们一起,将双十一的“性能炼狱”,变为我们技术体系的“最佳试金石”。

2 经典案例
1、线程不安全方法
问题描述:在压测过程中发现,发生了消息丢失。在数万次请求中,发生几次、几十次消息丢失,还是挺难被察觉到的,所以针对这种发送MQ消息的接口做压测时,要做针对性检验,具体就是记录当次压测的请求成功次数,记为X,分别记录对应消息队列的压测前入队数和压测后入队数并作差,记为Y,如果Y<X,就意味着发生了消息丢失。

排查过程:
- 首先,消息丢失通常发生在三个阶段:生产消息阶段、存储消息阶段、消费消息阶段,这次的消息丢失现象为消息未入队列,因此只能是第一种情况,也就是Java进程压根就没有向MQ发送消息。
- 接下来,梳理接口逻辑,基本流程简略示意如下,接口被调用后,主线程将计算任务提交给线程池,线程池的一个线程执行计算后发送MQ消息。

- 查看代码发现,线程池配置有点不太合理,一般cpu密集型任务,应将核心线程数设为cpu核心数+1,服务器是4核心的,但线程池核心线程竟然配置了200。
private ThreadPoolExecutor threadPool = new ThreadPoolExecutor(100, 200, Long.MAX_VALUE, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(1024*1024), new ReportThreadFactory("Report"));
- 以上配置显然不合理,于是将核心线程和最大线程设置为4、8,使用同样的目标TPS进行多轮压测,没有发生消息丢失。由于排期紧急,先期上线,但根源问题并未得到解决。
- 上个步骤说明,问题可能是过多线程并发导致的,但为何没有留下任务异常记录?于是排查调用链上的方法,注意到某个方法try…catch可能存在问题,这个段代码只catch了一个特定的异常,没有兜底逻辑,根据异常捕获机制:在 Java 中,
catch块仅能捕获其声明的异常类型或其子类。如果catch指定的异常类型与实际抛出的异常类型不匹配(且无继承关系),则该catch块不会执行,异常会继续向外层抛出,例如下面的代码。
try {
int a = 10 / 0; // 抛出 ArithmeticException
} catch (NullPointerException e) { // 不匹配 ArithmeticException
System.out.println("不会执行这里!");
}
- 高度怀疑是实际异常与声明异常不匹配,导致线程池的线程就那么悄无声息的终止了。进行处理后(增加
finally或多个catch且最后一个catch声明Exception,再次压测,终于捕获到了相关异常:一个线程不安全的日期工具类引发的血案。 - 代码中使用到了
SimpleDateFormat进行时间格式化处理,众所周知,SimpleDateFormat是线程不安全的,在并发情况下容易出现:Exception in thread "Thread-1" java.lang.NumberFormatException: For input string: ""
pubic class Demo{
private static final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
private static final SimpleDateFormat utcFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");//XXX为默认时区
**
* 将UTC时间转换为标准格式化时间
*/
public static String utcToStr(String utcDate, String resTag) {
try {
logger.info("resTag :{}, date: {}", resTag, utcDate);
return df.format(utcFormat.parse(utcDate));
} catch (ParseException parseException) {
logger.error("格式化错误!!!直接返回{}", utcDate);
return utcDate;
}
}
}
解决办法:定位到具体问题,解决起来就简单了,按照Java8的推荐使用DateTimeFormatter,或者将utcToStr方法使用synchronized关键字修饰,保证其线程安全。
总结:
- 使用到线程池时,一定要注意配置的合理性,否则会造成意料不到的问题,不过在本案例中,不合理的配置反而成了问题暴露的导火索。
- 异常处理注意要进行兜底处理,防止实际异常未被捕获,对后续流程造成影响。
- 并发场景中,要注意切勿使用线程不安全的类和方法。
2、分布式锁的错误使用
问题描述:在一个拼团购场景测试过程中,发生一个概率很低的问题——测试用户支付成功后达成拼成条件(团满员即成团),但依然显示订单未支付,而团状态为完成。

这种发生概率低而又严重的问题是相当令人头疼的,首先的难题就是复现。考虑到偶现问题通常与多线程并发有关,这里采用了高并发压测方式来尝试复现,再通过增加更多细节日志去排查。
排查过程:
- 首先梳理场景流程,简略流程如下图。订单分为普通订单和拼团订单,两种订单判断用户是否真正购买成功的逻辑不同——如果是普通订单,则只判断订单状态是否为【已支付】,是的话则判定用户购买成功;如果是拼团订单,则需要订单状态为【已支付】且拼团状态为【已成团】,才会判定用户购买成功。拼团模块收到订单模块支付成功的消息后,判断已成团,会修改订单的拼团状态为【已成团】。

- 首先排查异常案例,确认支付流程没有问题:所有订单都正常接收到了三方支付接口支付成功的消息,也就是说与外部支付接口的交互没有问题
- 进一步排查发现,订单模块是有正确修改过订单状态的,但最终却诡异地被改回去了,除了订单模块,那只有拼团模块有修改订单的可能了。拼团模块处理订单行的示意代码如下:
// 读取订单信息
OrderDto orderDto = OrderService.getOrderById(orderId);
// 将订单拼团状态置为已成团
orderDto.setGroupStatus("1");
// 更新订单行
orderService.updateOrder(orderDto);
- 通过日志观察到,拼团模块修改订单行的订单实体中,订单状态竟然是【待支付】,到此基本破案:在大多数情况下,订单模块修改订单行会早于拼团模块读取订单行,这样拼团模块读到的就是最新数据,但在某些特殊情况下,拼团模块后发先至,在订单模块修改订单之前就读取订单行,这样就读取到了过时数据,而后订单模块来修改订单了,紧接着拼团模块再次修改订单行,用错误的订单状态覆盖了订单行。这也是问题偶发且概率较低的一个原因。

- 但在这种场景下,不同模块处理同一个订单,必须有分布式锁来限制并发,为什么分布式锁没有生效呢?
// 伪代码:使用Redis分布式锁(Redisson实现)
// 伪代码:订单状态更新
public boolean updateOrderStatus(String orderNo, String newStatus) {
String lockKey = "order:status:lock:" + orderNo;
RLock lock = redisson.getLock(lockKey);
try {
if (lock.tryLock(2, 5, TimeUnit.SECONDS)) {
Order order = orderService.getByNo(orderNo);
// 检查当前状态是否允许变更(如不能从"已取消"→"已完成")
if (!order.canChangeTo(newStatus)) {
throw new RuntimeException("状态变更非法!");
}
return orderService.updateStatus(orderNo, newStatus);
}
} finally {
lock.unlock();
}
return false;
}
- 走查两个模块的代码发现,其实是一个非常低级的错误:订单模块使用orderId作为分布式锁的key,而拼团模块使用orderNo作为分布式锁的key,这当然导致分布式锁没有起到防并发的作用。
总结:
- 团队中关于一些通用规则要对齐和贯彻,例如分布式锁key的选取、数据库字段命名习惯等等。
- 对于这种复杂场景,要设计好并发测试场景,以发现和拦截手工测试难以发现的问题。
3、串行调用
现象:发票特征查询接口性能不佳,平均响应时间高达3s,梳理逻辑发现,该接口依赖大量外部接口获取数据后再进行计算,而这些外部接口是比较耗时的,加之是串行调用,导致接口性能十分低下。
解决方案:利用线程池管理并发任务,并借助CompletableFuture异步编程工具进行编排,可以使这些调用被同时发起。整个调用的总耗时将不再是个服务耗时的累加,而是取决于其中最慢的那个服务的响应时间,从而大幅降低整体延迟。这种方法尤其适用于依赖服务众多、逻辑链较长的复杂业务场景,是提升系统吞吐量和响应速度的关键技术手段。

4、频繁GC
GC,也就是垃圾回收,对系统的性能影响主要表现在CPU使用率、响应时间抖动,

对于GC问题的治理是一个非常庞大的课题了,这里简单记录几个遇到的案例。
1、循环前提前排除不匹配目标
private void handleByRule(BusinessReqContent businessReqContent) {
Iterator<ReqVo> iterator = subReqs.iterator();
while (iterator.hasNext()) {
BusinessReqVo businessReqVo = iterator.next();
// 根据规则再次循环调用
for (int i = 0; i < preBizRuleList.size(); i++) {
BizFilter bizFilter = preBizRuleList.get(i);
//人群过滤
..........
}
}
}
对集合直接双重循环处理,当请求量增大时,导致频繁的函数调用和对象创建,引发大量GC,影响性能。
解决方案:增加预处理逻辑,将不匹配目标提前排除,剩余目标进入循环。此举大幅减少循环次数,实现时间和空间占中的双重降低。
2、集合迭代器使用不当
private boolean filter( List<Filter> filters){
Iterator<Filter> iterator = filters.iterator();
while (iterator.hasNext()) {
Filter filter = iterator.next();
// 处理逻辑
}
}
遍历集合时使用迭代器(Iterator)会带来额外的对象创建和方法调用开销。迭代器对象的实例化及其hasNext()、next()等方法会增加调用的成本,使得循环体内的指令更加复杂。
解决方案:
改用数组循环,可以显著提升执行效率。
3、字符串使用问题
String的不当使用会导致创建大量对象,例如:
- 字符串拼接:
public static BizResult bizHandler(){
// 拼接测试标识
CallerInfo callerInfoTotal = Profiler.registerInfo("taobao.marketing.engine.selectAll"+(RpcContext.isTest()?".stressFlag":""));
return callerInfoTotal;
}
对于这种情况,建议直接设置字符串常量,直接使用。
- String.format()替换为StringBuilder
// 不当使用
public static String handle(String key) {
return String.format("vip_{%s}", key);
}
// 替换为
public static String handle(String key) {
return new StringBuilder().append("vip_{").append(key).append("}").toString();
}
4、减少反射的使用
反射过程中获取Method对象会创建大量临时对象,这些临时对象会迅速占满新生代,导致GC被频繁触发,在高并发场景下,这种开销是必须优化的重点。
// 使用反射
Object oldVal = CacheService.class.getMethod(methodName, ClassLoaderUtils.getClazzByArgs(args)).invoke(cacheServiceOld, args);
return oldVal;
// 直接方法调用
cacheServiceImpl.sMembers(key, cacheListKey)
5、避免大对象的直接分配
JVM为提升效率,设有“大对象直接进入老年代”的机制。频繁创建大数组、大字符串或未指定初始容量而导致频繁扩容的集合(如ArrayList、HashMap),会瞬间撑满老年代,引发频繁的老年代回收。
private Set<String> handle(String str){
if (!StringUtils.isEmpty(str)) {
Set<String> set = new HashSet<>(JSONArray.parseArray(str, String.class));
return set;
}
}
将大对象按功能拆分,可以有效避免其因存活时间过长而直接进入老年代,从而显著降低老年代的内存占用,是一种重要的内存优化策略。
//多线程查询
private Set<String> handle(String str){
List<Future<Set<String>>> futureList = new ArrayList<>();
for (int i = 1; i <= keyNum; i++){
int finalI = i;
Future<Set<String>> submit = consumeBigKeyThreadPool.submit(() -> {
if (!StringUtils.isEmpty(str)) {
Set<String> set = new HashSet<>(JSONArray.parseArray(str, String.class));
return set;
}
return new HashSet<>();
});
futureList.add(submit);
}
}
3 性能调优原则
- 循环优化:在进入循环前尽可能排除不符合条件的对象,减少循环次数;遇到多层循环时,好好考虑能否用空间换时间;
- 减少传输报文大小:在与数据层面或者应用间传输对象时,只传递有效字段的值,减少传输体积、做好内容压缩。
- 减少网络传输报文大小:例如目前商品主数据接口就是这么做的,需要获取的字段信息需要申请,只返回自己需要的字段,不会返回过多别的字段;减少报文大小、字段精简、长度压缩、内容压缩等也是常用手段;
- 做好流量漏斗:采用布隆进行流量漏斗,或者将热点请求提前计算好,减少下游代码的调用;
- 批量调用:这也是减少网络消耗的常用手段,对于缓存可以使用hashtag将相同用户的数据打到一个集群的一个分片中,然后使用pipeline一次获取;
- 选择适合的数据结构和集合大小:减少copy操作;java的集合操作addAll、putAll等操作会消耗大量CPU;可尽量减少集合类的使用和操作,特别是大数据情况;
- 快速失败机制:这个话题有很长的历史,但真正能做好的系统非常少;还是需要对系统进行深入研究,做好流量漏斗;如果后续流程中都可能存在流程中断的情况,那么就可以好好考虑能否前置;
- 并行处理:可以用多线程、进行并行处理;但需要注意别浪费了主线程;
- 异步处理:当前核心流程非必须100%依赖的操作考虑异步处理,要注意对异步失败场景进行兜底和补偿处理。
另外还有减少GC原则:
- 使用合适的数据结构:数据结构优先考虑简单化,比如尽量使用int而非Integer、long,尽量不适用Map。
- 减少复制:对象的复制和集合的扩容都容易产生大量垃圾,引发GC。
- 减少长生命周期的大对象:避免直接创建大对象,而是将大数据拆分后创建小对象。
经历有限,先写这些,感兴趣的同学可以多多发表意见,后续有新的心得会进行更新。

被折叠的 条评论
为什么被折叠?



