资讯动态

异步记录日志与结果获取:方案选型、实战代码与踩坑总结

发布时间:2026/10/14 12:21:16 来源:尧图企业网站定制
1. 这个需求到底在解决什么问题1.1 从一次线上事故说起去年我参与维护的一套交易系统高峰期每秒要处理上千笔请求每笔请求要写操作日志、审计日志、业务日志至少三条。当时用的是最传统的同步logback配置日志落盘直接挂在业务线程上。结果一到晚高峰磁盘I/O一抖动整个接口的RT从50毫秒直接飙到800毫秒最严重的时候业务线程池直接堆满新请求全部排队等待。那次事故让我意识到日志写入这种辅助性操作不该反噬主业务而异步记录日志正是解决这类问题最直接的思路。所谓异步记录日志就是业务线程把日志交给一个独立的线程或队列去处理自己立刻返回继续干正事。核心目的是把日志I/O的开销从业务链路上剥离出去让日志写入的延迟、失败都不再影响主流程。但异步也带来了新的衍生问题任务丢到后台之后你怎么知道它到底写完没有执行过程中有没有报错返回的结果对不对这就是标题里后半句获取异步处理结果的由来。如果你只是记录日志还好说但如果异步任务本身是有业务含义的——比如需要把处理结果回写库、需要通知下游、需要返回给调用方——那异步执行和结果获取就必须当成一个整体来设计。1.2 异步日志的本质把I/O开销从业务链路里剥出去把日志从同步改成异步本质上是在业务线程和真正执行写入的线程之间加了一个缓冲层。业务线程只需要把日志事件投递到一个内存队列里就算完事了。后续的格式化、编码、写文件、刷新磁盘全部由后台线程去完成。这样一来业务线程单次调用的耗时从毫秒级磁盘写入下降为纳秒级内存入队性能提升是数量级的。但是这里有一个很多人容易忽略的点异步并不等于零成本只是把成本转移了。队列有容量上限后台线程有处理速度上限一旦生产速度持续大于消费速度队列就会积压甚至溢出。所谓异步记录日志如果没有配套的背压机制、溢出策略和监控告警最终要么日志大量丢失要么内存被积压消息撑爆。这个问题在后面讲坑的时候我会展开细说。至于获取异步处理结果则取决于你选用的异步模型。如果你用的是Future结果获取就是一个轮询或者阻塞等待的过程如果你用的是回调机制结果是事件驱动反馈的如果你把任务发给消息队列结果可能根本不需要在发起方同步等待而是通过另一个消费者去落库。不同的模型决定了你的代码结构和排查方式完全不同所以选型这件事必须放在需求的最前面想清楚。2. 整体方案选型五种常见实现路径2.1 线程池包装最朴素也最常用的方案最简单粗暴的做法是自己new一个线程池把日志写入动作封装成一个Runnable丢进去执行。伪代码大概是这样业务方法调用日志服务日志服务内部将日志对象放入一个阻塞队列由固定数量的后台消费线程取出并写入文件。ExecutorService logExecutor new ThreadPoolExecutor( 2, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(10000), new NamedThreadFactory(async-log), new ThreadPoolExecutor.DiscardOldestPolicy() ); public void writeLog(LogRecord record) { logExecutor.execute(() - doWrite(record)); }这个方案的好处是零依赖、实现直观、可控性强。线程池的核心线程数、队列大小、拒绝策略全部由你决定。坏处是你需要自己处理的事情很多线程池的生命周期管理、优雅关闭、日志刷盘时机、异常处理、上下文传递。尤其是上下文传递这件事后面会单独讲。如果你只是想快速解决同步写日志导致的RT抖动问题线程池包装方案是最快见效的路径。但不建议直接在业务代码里裸写线程池逻辑正确的做法是封装一个独立的日志服务组件对外暴露统一的异步写日志接口内部细节全部隐藏。2.2 基于日志框架的异步Appender成熟且省心如果你用的是Log4j2或者Logback其实不需要自己实现异步。Log4j2提供了AsyncAppender底层用的是Disruptor环形队列吞吐量极高。Logback也有AsyncAppender配置起来也很简单。对于大多数场景直接用框架自带的异步Appender是性价比最高的选择。AsyncAppender nameASYNC queueSize1024 blockingtrue bufferSize512 AppenderRef refFILE/ /AsyncAppender以Log4j2为例它的AsyncAppender底层是基于LMAX Disruptor的这是一个号称单线程每秒能处理600万事件的无锁环形队列。相比JDK自带的有界队列Disruptor在吞吐量和延迟上都有明显优势。这套方案的最大优点是稳定可靠、经过大规模生产验证你不需要自己去处理线程池、队列、刷盘等一堆细节。缺点是定制性弱你没法在日志写入前后插入自己的业务逻辑也没法方便地拿到异步写入结果——因为框架根本不返回写入结果给你日志写完就完了。所以这里要先明确一个边界如果你的需求只是把日志异步写掉不关心结果直接上框架的异步Appender如果你的需求包含异步处理某件事并且还要拿到处理结果那么你需要的是异步任务框架而不是日志框架。2.3 消息队列跨服务的异步日志还有一种场景日志量极大本地磁盘根本扛不住或者需要多系统统一采集日志。这时候的做法是把日志发到消息队列比如Kafka然后由专门的日志消费服务去落地存储。业务系统只负责把日志封装成消息发出去完全不关心后续的存储和处理。kafkaTemplate.send(app-log-topic, logJson);这个方案的延伸意义在于它把记录日志和获取异步处理结果解耦到了两个不同的服务。发起方发出消息之后后续的处理过程对发起方是完全透明的发起方也不需要知道结果。如果确实需要结果一般会通过回调事件或者查询下游状态来完成。消息队列方案适合日志量非常大的中大型系统但它不是我们这篇文章的主角因为我个人的经验是绝大部分项目的日志量并没有大到本地磁盘都写不动的程度真正的问题只是同步写导致的RT抖动。杀鸡用牛刀反而引入了一套需要运维的中间件。2.4 方案对比与取舍我把这几个方案放在一张表里做一个对比方便你根据自己项目的实际情况选型。方案依赖特点能否获取执行结果适合场景线程池包装JDK自带灵活可控、代码量大可以用Future或回调需要异步执行并获取结果框架异步AppenderLog4j2/Logback配置简单、性能高不能纯写日志只关心日志异步落盘消息队列Kafka等解耦彻底、可扩展跨服务异步通常不直接返回日志量大、多系统统一采集事件机制Spring Event业务内解耦、代码侵入小可以通过事件回调业务模块内部异步处理响应式编程Reactor/CompletionStage函数式风格、链路优雅可以链式获取高并发且熟悉响应式编程说实话标题里的异步记录日志获取异步处理结果这一个组合最贴合的是线程池包装和事件机制这两种方案。因为它们既能做到异步执行又能通过Future、回调等方式把结果带回给发起方。后面我会以线程池包装方案为主线完整演示从设计、编码到上线排查的全过程。3. 获取异步处理结果的三种姿势3.1 Future 轮询简单直接但有点笨Java里最古老的异步结果获取方式就是Future。你把任务提交给ExecutorService得到一个Future对象然后调用future.get()来阻塞等待结果。FutureBoolean future logAsyncExecutor.submit(() - writeLog(record)); Boolean result future.get(5, TimeUnit.SECONDS);get方法有两个要命的特性第一是它会阻塞当前线程直到任务完成如果任务迟迟不结束你的业务线程就被卡住了——这就违背了异步解耦的初衷第二是它会抛受检异常你必须处理InterruptedException和ExecutionException代码比较啰嗦。有一种改良思路是加超时时间。future.get(5, TimeUnit.SECONDS)这样超过5秒就抛TimeoutException。但你想想主线程还是被阻塞了最多5秒。如果你的业务逻辑是发完日志立刻继续干活结果晚点再看Future的阻塞语义就不合适。所以我的判断是Future适合你愿意等待任务完成的场景比如批量提交一批日志然后统一等它们写完再往下走。如果你要的是完全不阻塞结果通过事件回来就得换模型。3.2 CompletableFuture回调事件驱动的正确姿势CompletableFuture是Java 8引入的增强版Future最大的特点是支持回调。你不用主动去get任务完成之后框架会自动触发你注册的whenComplete、thenApply、 exceptionally等方法。CompletableFuture.supplyAsync(() - writeLog(record), logExecutor) .whenComplete((result, throwable) - { if (throwable ! null) { collectError(record, throwable); } else { markSuccess(record); } });这个模式的好处是业务线程完全不被阻塞提交完任务就回去干别的结果通过回调异步反馈回来。特别适合日志写失败要补偿的场景正常情况不关心结果异常情况通过回调记录失败原因或者转人工处理。CompletableFuture还有一个很实用的能力是编排。你可以把异步日志和另一个异步任务用thenCombine组合起来等到两个都完成再统一处理。在分布式追踪、批量导出、异步审核这类场景里这种编排能力非常管用。3.3 关联ID 结果表跨线程获取结果如果你把任务丢给了消息队列发起方和执行方不在同一个进程里Future和CompletableFuture都没办法直接跨进程返回结果。这时候就需要一个关联ID方案发起方生成一个唯一的任务ID把它塞进请求体里随任务一起发送。执行方处理完成后把任务ID和处理结果写入一张结果表。发起方通过任务ID去查表就能知道任务处理到哪一步了。String taskId UUID.randomUUID().toString(); TaskPayload task TaskPayload.builder().taskId(taskId).data(record).build(); mqProducer.send(task-topic, task); // 后续只需要轮询 task_result 表 TaskResult result resultMapper.getByTaskId(taskId); if (result ! null result.getStatus() SUCCESS) { // 处理完成 }这套方案在很多真实系统里非常常见尤其是异步对账、异步导出文件、异步通知下游这样的场景。它的优点是彻底解耦、稳定可靠缺点是结果实时性差一些可能需要轮询或者靠下游回调来确认。我参与过的某银行异步对账系统就是这么做的发起对账请求拿到一个批次号下游处理完把对账结果文件放到SFTP指定目录再通过回调接口通知完成全程没有一个阻塞点。这三种姿势对应三种不同的异步模型实际工程里常常是混着用的。比如主流程用CompletableFuture做异步日志遇到需要跨系统的结果确认时再用关联ID方案。理解了每个模型的适用边界比死记API重要得多。4. 完整实操一个带结果获取的异步日志组件4.1 整体架构与流程设计下面我用一个完整的小例子演示如何搭建一个既能异步记录日志、又能拿回结果的日志组件。需求背景是模拟某后台管理系统的操作审计模块每次管理员执行敏感操作系统需要记录操作日志同时要把审计结果反馈给前端展示。由于审计需要追加额外信息比如IP归属地、风险评分整个处理大概要消耗50到100毫秒直接同步写在请求线程里不合适。我的设计是这样的业务请求进来之后把审计日志对象立即投递到异步LogService业务线程不等待直接返回给前端已受理。后台线程完成审计增强之后把结果通过CompletableFuture回调写回状态缓存。前端通过轮询状态接口获取最终审计结果如果审计异常则标记该条日志为失败状态并触发补偿提醒。整个流程分四层接收入口层、异步执行层、结果反馈层、补偿处理层。接收入口层负责参数校验和投递异步执行层负责真正的日志落盘和增强处理结果反馈层通过CompletableFuture把结果写回补偿处理层处理失败情况的告警和重试。4.2 核心代码实现先定义异步日志执行器这里最核心的是线程池的配置。我用的是自定义命名线程池方便排查问题时看线程dump定位到是哪组线程在干活。Component public class AsyncAuditLogExecutor { private final ThreadPoolTaskExecutor taskExecutor; public AsyncAuditLogExecutor() { this.taskExecutor new ThreadPoolTaskExecutor(); this.taskExecutor.setCorePoolSize(4); this.taskExecutor.setMaxPoolSize(8); this.taskExecutor.setQueueCapacity(2000); this.taskExecutor.setThreadNamePrefix(audit-log-worker-); this.taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); this.taskExecutor.initialize(); } public CompletableFutureAuditResult submitAsync(AuditLogRequest request) { return CompletableFuture.supplyAsync(() - processAudit(request), taskExecutor); } private AuditResult processAudit(AuditLogRequest request) { AuditResult result new AuditResult(); try { // 模拟耗时增强处理补充IP地理信息、风险评分等 EnrichedInfo enriched enrichInfo(request); // 写入审计日志表 auditLogMapper.insert(buildLogRecord(request, enriched)); result.setSuccess(true); result.setRiskScore(enriched.getRiskScore()); } catch (Exception e) { result.setSuccess(false); result.setErrorMsg(e.getMessage()); } return result; } }这里有个细节要注意CompletableFuture.supplyAsync传入的线程池必须是Spring的ThreadPoolTaskExecutor或者JDK的线程池不能直接用并行度默认的commonPool。因为ForkJoinPool.commonPool是全局共享的其他框架也可能在用一旦被你的异步日志任务占满会影响整个应用的其他并行任务。然后是结果反馈层。我用一个ConcurrentHashMap做结果缓存键是请求ID值是CompletableFuture的完成状态。前端轮询接口通过请求ID查这个Map查到结果就返回查不到就返回处理中。Component public class AuditResultRegistry { private final ConcurrentHashMapString, CompletableFutureAuditResult resultMap new ConcurrentHashMap(); public void register(String requestId, CompletableFutureAuditResult future) { resultMap.put(requestId, future); } public AuditResult getResult(String requestId) { CompletableFutureAuditResult future resultMap.get(requestId); if (future null || !future.isDone()) { return null; // 还在处理中 } return future.getNow(null); } }入口层的调用代码就非常简洁了收到请求构造日志对象提交异步执行注册结果监听立即返回受理成功。PostMapping(/api/admin/audit) public ResponseEntityString submitAudit(RequestBody AuditLogRequest request) { String requestId request.getRequestId(); CompletableFutureAuditResult future auditLogExecutor.submitAsync(request); resultRegistry.register(requestId, future); return ResponseEntity.accepted().body({\requestId\:\ requestId \,\status\:\RECEIVED\}); }4.3 线程池参数不是拍脑袋定的线程池参数这块我看到太多人直接抄网上的配置了。核心线程数设多少队列容量设多少都是有依据的最好根据你的实际调用量和耗时算一遍。假设审计日志峰值QPS是200单条日志的增强处理加落库平均耗时80毫秒。那么单线程每秒能处理约12.5条1000/80要支撑200 QPS理论上需要16个线程。但峰值只是瞬时状态如果按峰值定核心线程数大部分时间线程是空闲的浪费资源。所以我通常按平均QPS的2到3倍来定核心线程数比如日常平均QPS是50核心线程设4个余量充足最大线程设8个队列容量设为2000。队列容量2000意味着最多可以积压10秒的任务量2000/200如果积压超过10秒还没被消费说明消费能力严重不足这时候CallerRunsPolicy会让提交任务的那个业务线程自己执行任务起到天然背压作用。而DiscardOldestPolicy会导致日志静默丢失在很多业务场景里是不可接受的。注意拒绝策略的选择非常关键。如果你的异步任务是必须完成的审计日志用CallerRunsPolicy比DiscardPolicy安全得多。宁可让某个请求慢一点也不能让审计记录轻易丢失。如果是纯日志且允许部分丢失再考虑Discard策略。4.4 异步链路的上下文传递最容易翻车的地方异步任务最大的坑之一是线程切换导致上下文丢失。比如你的日志需要记录当前登录用户、traceId、租户ID这些信息存在ThreadLocal里。同步执行时一切正常一旦丢到线程池执行新线程的ThreadLocal是空的很多框架会报NPE或者数据错乱。解决思路有三种一是用TransmittableThreadLocal淘宝开源的TTL它能在线程池场景下自动传递上下文二是在提交任务前把上下文对象显式传参而不是依赖ThreadLocal三是把上下文塞进日志请求对象里一并提交。// 方案二示例显式传递上下文 public CompletableFutureAuditResult submitAsync(AuditLogRequest request, AuditContext context) { return CompletableFuture.supplyAsync(() - { // 在当前线程恢复上下文 try (AuditContextScope ignore AuditContextHolder.set(context)) { return processAudit(request); } }, taskExecutor); }我在真实项目里两种方案都试过。TTL确实好用但它是一个额外依赖而且需要配合框架初始化的Agent或者代码侵入不是所有人都有条件加。显式传参的方式更笨但更可控适合对依赖有强约束的团队。个人建议如果你的项目已经用了TTL直接上如果没用不要为了这一个场景引入新依赖显式传参会省很多麻烦。5. 实际部署中踩过的坑与排查实录5.1 线程池被打满日志任务反过来拖垮了应用有一次上线异步日志功能之后应用出现了诡异的周期性卡顿。查监控发现异步日志线程池的活跃线程数持续打满队列积压严重。进一步排查发现原来是有一段业务代码在循环里疯狂写日志而且每条日志里都包含了一个大对象的toString序列化耗时极高。问题出在哪我把序列化的动作放在了异步线程里以为异步了就不影响主流程了。这个思路本身没错但序列化的耗时变成了异步线程池的负担。一段正常的业务日志处理可能只需要5毫秒可序列化一个大对象就要50毫秒线程池的处理能力直接被吃掉大半积压越来越多最终连拒绝策略都触发了。解决办法是把序列化拆成两步第一步在业务线程里快速采集原始日志字段这一步基本无耗时第二步在异步线程里完成最终格式化落盘。这样就把纯I/O开销和业务逻辑开销区分开了异步线程只处理纯I/O性能瓶颈大幅缓解。这也给了我一个教训异步化只是把耗时成本从主线程转移到了后台线程总量并没有减少。如果你的异步线程池配置得很小而任务处理又很重最终一样会出问题。评估异步方案时不要只看主线程的RT还要看后台线程的总负荷。5.2 应用重启产生的日志丢失另一个让我记忆深刻的坑是应用重启时的日志丢失问题。异步日志任务还存在线程池队列里还没有来得及处理结果应用因为发布被kill掉了。那些在队列里和正在处理中的日志直接没了。这是异步日志架构的一个固有缺陷日志从同步必达变成了异步尽最大努力。如果你的日志非常重要比如审计日志、对账日志、支付流水这种丢失是不可接受的。我见过一个团队因为这个事故被通报因为他们把订单状态变更日志改成了异步结果一次发布导致几十条变更日志丢失出问题后根本查不到是谁改的。针对这个问题我整理了三层保障措施第一在应用关闭时做优雅停机。Spring Boot里通过实现DisposableBean或者注册ShutdownHook在应用退出前调用线程池的shutdown()并等待队列中任务完成给积压日志一个最后抢写的机会。Override public void destroy() { taskExecutor.shutdown(); try { if (!taskExecutor.awaitTermination(30, TimeUnit.SECONDS)) { taskExecutor.shutdownNow(); } } catch (InterruptedException e) { taskExecutor.shutdownNow(); Thread.currentThread().interrupt(); } }第二把最重要的日志做成同步异步双写模式关键日志用同步兜底普通日志走异步。虽然这样关键日志的RT还是会受影响但为了数据可靠性这个代价是值得的。第三如果连双写都觉得不够可靠就考虑落本地临时文件或者发MQ用持久化手段替代内存队列。MQ的可靠性在这里体现得淋漓尽致消息没被确认消费之前即使消费者重启了也能从消息队列重新拉取。5.3 上下文丢失导致的数据混乱前面提到过上下文传递的问题这里说一个真实案例。某系统上线异步日志后发现审计日志里的操作人字段经常错乱有的人明明操作的是A模块日志里显示的却是上一个请求的用户。定位下来就是ThreadLocal上下文被线程池复用导致的。线程池中的线程执行完一个任务后会继续执行下一个任务如果你在异步方法里读取了ThreadLocal而这个ThreadLocal是前一个任务残留的数据那日志就会记错操作人。更隐蔽的是这个bug是间歇性的只有并发量高、线程被复用的频率高时才会频繁出现。刚开始我们用用完就清理ThreadLocal来解决但后来发现清理的时机很难把握有时候下一个任务还需要这个值clean早了丢数据clean晚了串数据。最终换成了显式传参的方式把操作人、租户ID这些字段直接封装到日志请求对象里进入异步线程后从参数里取彻底绕开ThreadLocal的坑。实操心得异步任务里尽量不要依赖ThreadLocal去取业务上下文特别是那些会被线程池复用的场景。显式传参虽然代码看起来啰嗦但它在并发场景下的正确性是最有保障的。5.4 结果获取超时与永远等不到CompletableFuture的回调模式下另一个常见问题是结果永远不会到来。比如前端提交了一个异步请求后台线程在处理时抛了异常而你在提交代码里没有注册exceptionally处理分支那么future永远不会完成前端也就永远轮询不到结果。我踩过的一次就是某个异步任务在补数据时调用了外部接口外部接口超时任务抛异常。因为没有设置exceptionally兜底future一直处于未完成状态结果缓存里永远查不到前端一直显示处理中。解决方案是统一封装一层守护逻辑在所有异步任务的尾部增加whenComplete或者exceptionally保证无论成功失败future都会被标记为完成状态并且把异常信息写入结果对象。CompletableFutureAuditResult future CompletableFuture.supplyAsync(() - processAudit(request), taskExecutor) .exceptionally(throwable - { AuditResult errorResult new AuditResult(); errorResult.setSuccess(false); errorResult.setErrorMsg(异步任务异常: throwable.getMessage()); return errorResult; });另外一个经验是给异步任务加一个超时兜底。CompletableFuture默认没有超时中断机制除非业务代码自己控制否则任务可能永远挂在那里。Java 9之后可以用completeOnTimeoutJava 8项目可以自己实现一个定时检查的ScheduledExecutor来兜底。这属于进阶操作了如果你的项目对结果实时性有要求建议提前设计好。5.5 排查工具的实战用法异步问题的排查比同步麻烦很多因为线程是来回切换的同一个请求的执行链路被打散在不同线程里。我的排查三板斧是日志追踪ID、线程dump、队列积压监控。日志追踪ID是最基本的。设计异步日志组件时必须让每一条入队日志和它的处理日志共享同一个traceId这样才能在日志平台上把提交、处理、落库三个环节串起来。没有traceId排查异步问题等于摸黑。线程dump是我在定位线程池耗尽问题时最常用的手段。执行jstack命令抓下线程dump重点看处于WAITING状态的worker线程在哪个代码位置卡住了。如果是锁竞争dump文件里能看到明显的BLOCKED状态如果是外部调用超时能看到线程停在网络调用的栈帧上。线程池的命名前缀比如我前面样例里的audit-log-worker-在这个时刻尤其重要没有规范的线程命名你根本分不清哪些线程是归属哪个线程池的。队列积压监控是最能提前发现问题的指标。对线程池的activeCount、queueSize做定时采样丢到监控系统里。一旦发现queueSize持续上涨说明消费能力跟不上要尽早干预。我习惯在queueSize超过队列容量80%时触发告警超过100%时触发紧急扩容通知。6. 异步日志后续可以扩展的方向这套异步执行结果获取的模式除了记录日志之外其实可以平移到很多类似的场景。比如异步导出Excel、异步推送通知、异步生成报表业务逻辑都是一样的先把请求接下来后台慢慢处理处理完通过某种方式把结果反馈回去。你只要替换掉任务内容和结果反馈形式骨架完全复用。我个人的看法是异步不是一个加了就能提升性能的银弹它是一个需要通盘考虑的技术决策。你要想清楚哪些操作可以异步、哪些必须有结果、哪些结果可以被丢、哪些必须成功。把这些边界定清楚了异步方案才能真正帮你解决问题而不是把同步问题变成异步问题。再分享一个小技巧如果你用的是Spring Boot可以在启动类上加上EnableAsync然后用Async注解配合自定义线程池代码会更简洁。但注意Async注解的默认线程池是SimpleAsyncTaskExecutor这个类每次都会新建线程在高并发场景下非常危险一定要显式指定你自己的线程池。最后说一句实在话我在这个组件上踩过坑之后对异步日志的态度变得更加务实了。不是所有日志都值得异步也不是所有异步都要回传结果。我现在的习惯是给日志分三级普通调试日志直接异步能丢操作记录日志异步处理但需要有补偿机制关键审计日志同步落库加异步增强处理。分级之后系统的性能和可靠性就找到了一个比较舒服的平衡点。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑