公司搞末位淘汰,一个24岁的员工从来不加班,但产出是别人的两倍;另一个30岁的老员工每天耗到晚上10点,却没多少实际产出。最后,准时下班的人反而被裁了。
![]()
先不讨论这个淘汰标准合不合理。做后端久了,我看到“每天忙到10点”会先想到另一件事:有些系统也特别忙,线程很多、CPU很高、日志疯狂刷,看起来一刻没闲着,但真正完成的业务量并不高。我们线上就碰到过一次,订单服务CPU长期70%以上,线程池几乎打满,扩了机器还是扛不住,最后发现大量计算资源根本没产生有效产出。
CPU很忙,订单却没处理多少
当时是一个订单履约服务。支付成功后,消费MQ消息,查询订单、锁定库存、生成履约单,再更新订单状态。
最早业务量不大,这段代码一直没出过问题:
publicvoidhandle(PaySuccessEvent event){
Order order = orderMapper.selectById(event.getOrderId);
if (!"PAID".equals(order.getStatus)) {
return;
}
for (OrderItem item : order.getItems) {
Stock stock = stockClient.query(
item.getSkuId,
order.getWarehouseId
);
if (stock.getAvailable < item.getQuantity) {
thrownew BizException("库存不足");
}
stockClient.lock(
item.getSkuId,
item.getQuantity
);
}
fulfillmentClient.create(order);orderMapper.updateStatus(
order.getId,
"PAID",
"FULFILLING"
);
}
代码不复杂,当时这么写也有理由:一个订单平均只有两三个SKU,库存服务RT不到20ms,每秒订单量只有几十。为了快速上线,把库存查询和锁定直接放循环里,成本最低。
问题出现在一次活动之后。订单峰值涨了差不多6倍,机器CPU从30%涨到75%,消费线程也从32加到了96,但MQ积压反而越来越严重。
监控第一眼很容易让人误判:CPU高、线程多,所以我最开始怀疑是JSON反序列化或者某段计算逻辑吃CPU。
order-consumer activeThreads=94
queueSize=1876
processCpu=76%
jvmCpu=71%MQ lag:
10:05 12,431
10:10 28,903
10:15 51,772
于是先抓了JFR,又看了GC。结果Young GC很正常,没有频繁Full GC,CPU热点也没有某个特别夸张的方法。数据库慢SQL同样没发现问题,订单查询基本都在10ms以内。
这时候方向已经不对了。
真正的问题藏在线程状态里
继续看线程栈,才发现大量消费线程都停在HTTP调用上:
"order-consumer-71"
java.lang.Thread.State: WAITING
at CompletableFuture.get(...)
at StockClient.query(...)
at OrderHandler.handle(...)"order-consumer-72"
java.lang.Thread.State: WAITING
at CompletableFuture.get(...)
at StockClient.lock(...)
再按调用链统计,一个8个SKU的订单会产生16次库存RPC:8次query,8次lock。
活动开始后库存服务本身也变慢了:
stock.query p95 = 86ms
stock.lock p95 = 112msorder.handle p95 = 1843ms
这就解释了为什么加线程没用。单个消费线程两秒左右才能处理一个订单,其中绝大部分时间都耗在RPC等待和上下文切换上。96个线程看起来全部在工作,真正完成的订单却没增加多少。
而且原来的实现还有一致性问题:假设前5个SKU锁定成功,第6个库存不足抛异常,前面已经锁掉的库存怎么办?如果依赖MQ重试,同一条消息再次进来,还可能重复锁库存。
所以这次没有继续调线程池,而是先改库存接口粒度。
publicvoidhandle(PaySuccessEvent event){
Order order = orderMapper.selectForUpdate(event.getOrderId);
if ("FULFILLING".equals(order.getStatus)) {
return;
}
if (!"PAID".equals(order.getStatus)) {
thrownew BizException("订单状态异常");
}
String requestId = "FULFILL:" + order.getId;
List items = order.getItems.stream
.map(i -> new LockItem(i.getSkuId, i.getQuantity))
.toList;
BatchLockResult result = stockClient.batchLock(
requestId,
order.getWarehouseId,
items
);
if (!result.success) {
thrownew BizException(result.message);
}
orderMapper.updateStatus(
order.getId,
"PAID",
"FULFILLING"
);outboxMapper.insert(
requestId,
"CREATE_FULFILLMENT",
order.getId
);
}
这里没有简单地把16次同步调用改成16个 CompletableFuture 。并发RPC确实能降低单个订单耗时,但会把压力直接放大到库存服务,活动峰值下很容易把下游连接池打穿。
我们最后让库存服务提供批量锁定接口,一次请求完成整单库存校验和锁定。
幂等不能只靠订单状态
批量接口里还有一个关键点: requestId 必须参与幂等,否则订单服务超时后重试,库存服务其实已经成功,第二次请求仍然可能重复扣减。
库存侧用了唯一键兜底:
CREATEUNIQUEINDEX uk_stock_request
ON stock_lock_record(request_id);
核心逻辑不是先查再插,而是让数据库约束处理并发:
@Transactional
public BatchLockResult batchLock(LockCommand cmd){
try {
lockRecordMapper.insert(
cmd.requestId,
"PROCESSING"
);
} catch (DuplicateKeyException e) {
return queryPreviousResult(cmd.requestId);
}
int affected = stockMapper.batchLock(
cmd.warehouseId,
cmd.items
);
if (affected != cmd.items.size) {
thrownew StockNotEnoughException;
}lockRecordMapper.markSuccess(cmd.requestId);
return BatchLockResult.success;
}
库存更新本身也不能先 select 再由Java判断,否则并发订单可能同时读到“库存充足”。
实际SQL直接把库存条件放进更新:
UPDATE sku_stock
SET available = available - #{qty},
locked = locked + #{qty}
WHERE warehouse_id = #{warehouseId}
AND sku_id = #{skuId}
AND available >= #{qty};
affected_rows = 0 就说明库存已经不足。批量锁定在同一个本地事务中执行,只要其中一个SKU失败,整单回滚,不再留下“前5个成功、第6个失败”的半完成状态。
订单事务里也不能直接创建履约单
库存问题解决后,还有一个坑:如果库存已经锁成功,订单状态也更新成功,但调用履约服务时超时,MQ重试会再次走完整流程。
所以最终没有在订单事务里直接调用履约服务,而是落一条outbox事件:
@Transactional
publicvoidconfirmFulfillment(Long orderId){
int rows = orderMapper.casStatus(
orderId,
"PAID",
"FULFILLING"
);
if (rows == 0) {
return;
}outboxMapper.insert(
"FULFILL:" + orderId,
"CREATE_FULFILLMENT",
orderId
);
}
后台任务只负责可靠投递:
SELECTid, biz_key, payload
FROM event_outbox
WHEREstatus = 'NEW'
AND next_retry_time <= NOW
ORDERBYid
LIMIT100;
发送成功改成 SENT ,失败记录重试次数并延迟下一次执行。履约服务继续按 biz_key 做唯一键幂等。这样数据库事务只负责本地状态和事件落库,不再把远程RPC塞进事务里长期占着数据库连接。
改完之后,同样流量下消费线程从96降回32,订单处理p95从1.8秒降到300ms以内,MQ也不再持续积压。这个问题最有意思的地方是:监控上所有线程都“忙”,但大量工作其实只是等待、重试和重复调用。
回到末位淘汰这件事,工程系统里同样不能拿“忙了多久”衡量产出。线程跑到晚上10点没有意义,最终还是得看吞吐、延迟、错误率,以及到底完成了多少有效业务。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.