《Java 编程实战》12.3 日志规范:脱敏、采样与成本
日志里能放什么、该留多久、量有多大,是三个独立问题。本节用 libraryd 的真实输出演示邮箱与令牌脱敏、CRLF 日志注入、按比例采样与 Logback TurboFilter,并算清不同请求量下的日志体积,最后给出一份可执行的级别与保留策略。
tag
日志里能放什么、该留多久、量有多大,是三个独立问题。本节用 libraryd 的真实输出演示邮箱与令牌脱敏、CRLF 日志注入、按比例采样与 Logback TurboFilter,并算清不同请求量下的日志体积,最后给出一份可执行的级别与保留策略。
文本日志无法按 requestId 聚合一次借阅的多个步骤。本节用 logstash-logback-encoder 9.0 把 libraryd 的日志输出成 JSON,讲清 MDC 的线程绑定与泄漏、requestId 与 traceId 的生命周期、StructuredArguments 字段,以及线程池之间的上下文传递。
libraryd 的日志一度混着 Javalin 自带的 slf4j-simple,格式和级别都不受控。本节用 SLF4J 2.0.20 与 Logback 1.6.6 的真实输出讲清门面与绑定的关系、级别继承、Appender 与 encoder、参数化日志和异常打印,最后落到 libraryd 的两份配置文件。
定时任务可能因为重启、重试或多实例被跑两次。本节先用 job_runs 的唯一键和 on conflict do nothing 抢占执行权,再用真实输出说明第二次抢占返回 0;接着展示一个真实踩坑:用 fine_cents = 0 当未结算标记,第二次运行又处理了 1546 行零罚金记录,最后用 fine_settled_at 修正。
6000 行借阅记录,逐条插入约 11–21 秒,500 条一批约 0.17–0.44 秒;5400 行罚金结算同样差距明显。本节在同一台机器上跑出这组数字,再讲清楚 PostgreSQL 游标、keyset 分页、每批提交与按 id 分片,最后把 FineSettlementJob 写成可重入、可观测的批处理。
libraryd 需要每天扫逾期、每月结算罚金。本节用 ScheduledExecutorService 的 fixedRate 与 fixedDelay 实跑对比:200ms 任务配 100ms 周期时,fixedRate 每约 200ms 才启动一次,fixedDelay 每约 300ms 一次;再说明周期任务抛异常会静默停止,以及如何用注入的 Clock 算出下一次整点执行时间。
异步任务换线程之后,MDC 里的 requestId 会变成空,ThreadLocal 也读不到;InheritableThreadLocal 只在创建线程时复制一次,线程池复用后照样丢。本节用真实日志展示这三类丢失,给出 MdcExecutor 传递与清理方案,并讨论阻塞 JDBC、orTimeout 不取消上游、异常解包这几个边界。
一次会员借阅详情要查借阅、会员和书目,串行是 600ms,并行是 210ms,但并行不等于无限扇出。本节用 thenCompose 表达两波依赖,用 allOf 合并独立查询,并把每本书一次查询的 N+1 改成一次 findByIsbns 批量查询,最后用信号量把数据库并发压回连接池能承受的范围。
CompletableFuture 不是把 Future 换个名字:thenApply、thenCompose、thenCombine 决定依赖关系,orTimeout 与 exceptionally 决定失败路径。本节用 500ms 任务配 200ms 超时、allOf 单任务失败等输出,说明超时不会取消上游、allOf 会等所有任务结束,再把并行查询接进 libraryd 搜索。
线程池满了之后,AbortPolicy 只会抛一个 RejectedExecutionException,调用方看到的却是 500。本节把队列深度、活跃线程数和拒绝计数变成可观测信号,用自定义拒绝处理器把过载映射成 503,并讨论 CallerRunsPolicy 为什么会把压力传回 Jetty 请求线程。
JDK 25 的虚拟线程早已不是预览特性。本节先在同一个 JVM 参数下对比 2000 个平台线程与 2000/100000 个虚拟线程的创建耗时和 RSS,再用 Javalin 7.2.4 的 concurrency.useVirtualThreads 打开虚拟线程,最后用 JFR 确认 synchronized 在 JDK 25 里不再 pin,并说明结构化并发为何仍不进入本卷。
libraryd 的阻塞查询不能直接跑在 Jetty 请求线程上。本节用 ThreadPoolExecutor 把核心线程数、最大线程数、有界队列和拒绝策略讲清楚,并用 2 个线程、3 个队列位的饱和实验展示第 5 个任务之后会发生什么,最后把线程池接进 AppMain 与优雅停机。
领域层抛 LibraryException,持久层抛 SQLException,web 层只认识 Problem Details。本节把三层翻译串起来:SQLState 怎么变成错误码,错误码怎么决定 HTTP 状态,异常处理器为什么按最具体类型匹配,以及 404、405 和处理器自己抛异常时会发生什么。
同样是 400,有的接口返回字符串,有的返回对象,客户端只能按状态码猜。本节按 RFC 9457 把 libraryd 的错误响应固定成 application/problem+json,讲清 type、title、status、detail 与扩展成员 code、requestId、errors,并用真实响应说明媒体类型被覆盖的坑。
libraryd 的 bodyAsClass 只负责解析,不负责判断字段是否为空、超长或越界。本节用 Jakarta Validation 3.1.1 与 Hibernate Validator 9.1.5 把约束写在 record 上,把 ConstraintViolation 收成字段错误 Map,并用真实输出说明 Expressly 缺失、null 与空白、消息语言和领域校验的分工。
进程收到 SIGTERM 时,libraryd 正在处理的借阅请求会不会被切断,连接池和定时任务会不会来不及收尾。本节用两次真实停机实验对比 stopTimeout=0 与 10 秒的差别,再给出 LibraryApplication 的关闭顺序、幂等关闭、启动失败回滚和 shutdown hook 的完整写法。
libraryd 的每个 handler 都在重复读请求头、拼日志、记耗时。本节用 Javalin 的 before/after 把请求 ID、MDC、访问日志和指标收进中间件,并用真实执行顺序说明异常处理器与 after 的先后关系,最后给出请求头信任、MDC 清理与指标基数的边界。
libraryd 的服务层已经能借书还书,却还没有 HTTP 入口。本节用 Javalin 7.2.4 把路由挂在配置对象上,接通路径参数、查询参数、分页默认值与 Jackson,并用真实请求验证 201、204、Location、日期序列化和内容类型检查,最后给出完整路由清单。
审计事件要按类型还原,领域对象互相引用又会让 Jackson 无限递归。本节真实复现多态类型丢失、嵌套深度 1002 的报错,并演示如何把事件安全写入 PostgreSQL 的 jsonb 列、再用宽松 mapper 读回,最后给出 libraryd 的 AuditEvent 与仓储写法。
领域 record 不是 API 契约:Loan 没有 status 和 overdueDays,请求直接绑定领域对象又会带来批量赋值。本节用 Jackson 真实输出设计 BookDto、LoanDto、PageResponse 与 ApiError,划清请求、响应和领域对象的边界。