《Java 编程实战》14.3 分布式缓存:Redis 与一致性取舍
两个 libraryd 实例,A 更新了图书,B 却还在返回旧标题。本节从真实的两实例输出出发,讲清 Redis 的 key、TTL 与序列化约定,Jedis 的 300ms 超时和故障降级,写路径的失效顺序,以及 Pub/Sub、短 TTL、版本号三种一致性方案的取舍。
tag
两个 libraryd 实例,A 更新了图书,B 却还在返回旧标题。本节从真实的两实例输出出发,讲清 Redis 的 key、TTL 与序列化约定,Jedis 的 300ms 超时和故障降级,写路径的失效顺序,以及 Pub/Sub、短 TTL、版本号三种一致性方案的取舍。
查不到的 ISBN 为什么每次都能打到数据库?热点 key 过期的一瞬间,八个线程会不会同时回源?本节用 libraryd 的真实输出拆解缓存穿透、击穿与雪崩,给出负缓存、Caffeine 单飞、TTL 抖动和 Cache-Aside 写路径的取舍,并指出多实例下仍会漏掉的并发窗口。
同一本图书在一分钟内被查询几百次,每次都打 PostgreSQL 既慢又浪费。本节用 Caffeine 3.3.1 给 libraryd 加本地缓存,讲清 maximumSize、expireAfterWrite、refreshAfterWrite 与 recordStats 的取舍,并用真实输出解释 estimatedSize 为什么暂时偏大、刷新为什么先返回旧值。
数据库挂掉时 /health 该返回 200 还是 503?本节从 libraryd 真实的 5 秒探测延迟出发,讲清存活、就绪与启动探针的边界,拆解 HealthProbe 的 connection.isValid(2) 到底限制了什么,Hikari 的 connectionTimeout 如何主导探针耗时,并给出 Kubernetes 探针参数与拆分方案。
当一次借阅请求跨过网关、libraryd 和数据库,只有 requestId 的日志就拼不出完整链路。本节讲清 trace、span 与 W3C traceparent 的字段含义,拆解 libraryd 的 Telemetry 与 TracingMiddleware 如何提取父上下文、创建 SERVER span、回写响应头,并用真实输出讨论采样、子 span 与常见错误。
日志告诉你发生了什么,指标告诉你发生得有多频繁。本节用 Micrometer 1.17.2 与 Prometheus registry 给 libraryd 接上计数器、计时器和仪表,讲清命名、tag 基数、SimpleMeterRegistry 测试写法,以及 /metrics 的真实抓取输出。
日志里能放什么、该留多久、量有多大,是三个独立问题。本节用 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,并用真实响应说明媒体类型被覆盖的坑。