夜雨聆风学习资料网

ARTICLE · 1038025

上周隔壁工位的同事被裁了,我打开文档一看,他的KPI全划到了我名下,我一个人的目标变成了原来的1.8倍.

上周隔壁工位的同事被裁了,我打开文档一看,他的KPI全划到了我名下,我一个人的目标变成了原来的1.8倍.

某网友吐槽,同事被裁后拿着N+1离开,第二天 leader 直接把对方 Q3 的目标划到自己名下,一个人的目标变成原来的1.8倍。

先不讨论这个目标是否合理,我看到这个场景第一反应反而是之前接手过的一套批处理系统:业务量涨了接近一倍,机器和线程数没变,需求却要求处理时效不变。

这种问题在 Java 系统里很典型。吞吐量不是把任务数乘1.8,线程池也跟着乘1.8就能解决。系统里只要有一个共享瓶颈,扩线程甚至会让它更慢。

当时有个结算任务,每天凌晨扫描待处理记录,调用计费服务,再更新处理状态。最初数据量不大,代码非常直接:

List<Long> ids = settlementDao.queryPending(2000);for (Long id : ids) {    executor.execute(() -> settle(id));}voidsettle(Long id){    Settlement row = settlementDao.findById(id);    BillingResult result =        billingClient.calculate(row.getAccountId());    settlementDao.markSuccess(        id, result.getAmount()    );}

线程池配置也不复杂:

new ThreadPoolExecutor(20,20,0,    TimeUnit.SECONDS,new LinkedBlockingQueue<>(10000),new ThreadPoolExecutor.CallerRunsPolicy());

每天40万条的时候,凌晨任务大概两小时结束。后来业务合并,待处理数据涨到70万左右,产品要求仍然在原来的窗口内跑完。

最自然的处理方式就是加线程。20改成40,上线以后确实快了一点,但远没有达到两倍。更麻烦的是凌晨开始出现接口超时:

02:13:41 BillingClient - read timeout, cost=3012ms02:13:42 BillingClient - read timeout, cost=3008ms02:13:43 HikariPool - Connection is not available,request timed out after 3000ms

我一开始判断是数据库压力上来了,因为 Hikari 已经报连接获取超时。查慢 SQL,却没发现特别异常的语句。

SELECTid, account_idFROM settlementWHEREstatus = 0ANDid > ?ORDERBYidLIMIT2000;

执行计划走的是联合索引:

KEY idx_status_id(status, id)

单次查询基本在几十毫秒以内。继续看监控才发现方向错了:数据库连接池耗尽只是结果,真正先被打满的是下游计费接口。

调用链数据很明显:

settlement-worker    -> billing-service20 threads:QPS       185P99       420mstimeout   0.2%40 threads:QPS       207P99       2.8stimeout   11.7%

线程翻倍以后,下游吞吐量只从185涨到207,P99却从420ms冲到了2.8秒。大量工作线程卡在 HTTP 调用上,同时事务没有及时结束,最终把数据库连接也占住了。

这里其实还有个更隐蔽的坑。当时 settle() 外层加了事务:

@Transactionalpublicvoidsettle(Long id){    Settlement row = settlementDao.findById(id);    BillingResult result =        billingClient.calculate(row.getAccountId());    settlementDao.markSuccess(        id, result.getAmount()    );}

这意味着线程调用下游的几百毫秒甚至几秒里,数据库事务一直没有结束。业务量小时问题不明显,一旦下游开始抖动,连接池就会被迅速拖死。

所以第一步没有继续扩线程,而是把远程调用移出事务,只让最终状态更新进入短事务。

publicvoidsettle(Long id){    Settlement row = settlementDao.findById(id);    BillingResult result =        billingClient.calculate(row.getAccountId());    transactionTemplate.executeWithoutResult(tx -> {int affected = settlementDao.markSuccess(            id, result.getAmount()        );if (affected != 1) {thrownew IllegalStateException("settlement state changed: " + id            );        }    });}

对应 SQL 也加上状态条件:

UPDATE settlementSETstatus = 1,    amount = ?,    finished_at = NOW()WHEREid = ?ANDstatus = 0;

这样即使任务因为超时发生重复调度,也不会无条件覆盖已经处理完成的数据。

事务缩短以后,Hikari 超时基本消失,但70万任务还是无法在两小时内完成。这时候不能再靠“线程数感觉应该够”来判断,而是直接算吞吐量。

两小时处理70万条:

700000 / 7200 ≈ 97 TPS

单次结算不是只有一次调用。高峰时计费接口P95大约650ms,如果40个线程大量时间都在等待远程响应,理论并发需求已经接近:

97 × 0.65 ≈ 63

但直接开64个线程还是会重新压垮下游,所以后来把“本地执行能力”和“下游允许吞吐量”拆开。线程池负责消费,限流器控制真正进入计费服务的并发。

privatefinal Semaphore billingSlots =new Semaphore(24);voidsettle(Long id){boolean acquired = false;try {        acquired = billingSlots.tryAcquire(500, TimeUnit.MILLISECONDS        );if (!acquired) {            retryQueue.offer(id);return;        }        doSettlement(id);    } catch (InterruptedException e) {        Thread.currentThread().interrupt();    } finally {if (acquired) {            billingSlots.release();        }    }}

这里没有简单地把线程池缩回20。线程池可以维持较高并发,因为任务还有查库、状态更新等操作;真正昂贵的下游调用单独限制并发。这样某个依赖变慢时,不至于把整个 worker 的资源一起拖死。

继续看调用链后发现,每条记录都会先根据 account_id 查询账户配置。70万条记录实际只有8万多个账户,同一个账户配置被重复查询很多次。

原来的代码:

for (Settlement task : tasks) {    AccountConfig config =        configDao.findByAccountId(task.getAccountId());    calculate(task, config);}

后来每批2000条先把账户ID去重,再批量查询:

Set<Long> accountIds = tasks.stream()    .map(Settlement::getAccountId)    .collect(Collectors.toSet());Map<Long, AccountConfig> configMap =    configDao.findByAccountIds(accountIds)        .stream()        .collect(Collectors.toMap(            AccountConfig::getAccountId,            Function.identity()        ));for (Settlement task : tasks) {    calculate(        task,        configMap.get(task.getAccountId())    );}

SQL 从大量单行查询变成批量查询:

SELECT account_id, rate, rule_versionFROM account_configWHERE account_id IN (?, ?, ...);

最终数据库 QPS 明显下降,任务完成时间才真正压回窗口内。整个过程中没有上 MQ,也没有拆新服务,因为当时的问题还没严重到需要改变任务模型;缩短事务、限制下游并发、消除重复 IO,已经能解决。

回到那个“一个人接1.8倍目标”的场景,软件系统也经常收到类似要求:流量涨80%,资源和时效不变。这个时候最危险的操作,就是把线程数也乘1.8。先把调用链里的真实瓶颈找出来,通常比继续堆并发有效得多。

相关学习资料