《Java 编程实战》14.3 分布式缓存:Redis 与一致性取舍

两个 libraryd 实例,A 更新了图书,B 却还在返回旧标题。本节从真实的两实例输出出发,讲清 Redis 的 key、TTL 与序列化约定,Jedis 的 300ms 超时和故障降级,写路径的失效顺序,以及 Pub/Sub、短 TTL、版本号三种一致性方案的取舍。

本节目标:理解 libraryd 的 Redis 层如何设计 key、TTL、序列化和连接超时,能在 Redis 连接失败或运行期超时时正确降级;同时能解释两个实例为什么看到不同的图书标题,并在「短 TTL、失效广播、版本号」之间做出有依据的取舍。
适用版本:JDK 25(Temurin 25.0.5)、Jedis 8.0.2、Redis 7、Caffeine 3.3.1、PostgreSQL 17。

两个实例看到不同标题

把 libraryd 部署两个实例,A 和 B 都连同一个 Redis 和 PostgreSQL。B 先查了一次《Effective Java》,本地 Caffeine 缓存了旧标题;A 随后把标题改成《Effective Java 3rd》,写数据库、写 Redis,并更新 A 自己的本地缓存。此时 B 再查同一个 ISBN,会看到什么?

用 CacheStaleDemo 模拟这个顺序,输出是:

B first read=Effective Java
redis after A update=Effective Java 3rd
B second read=Effective Java
database after A update=Effective Java 3rd

这是本机输出,环境为 JDK 25.0.5、Caffeine 3.3.1、Redis 7。三行结果放在一起看:

  • B 第一次读,本地没有,从数据库加载《Effective Java》,同时写入本地和 Redis。
  • A 更新后,Redis 里已经是《Effective Java 3rd》,数据库也是新值。
  • B 第二次读,本地缓存直接命中,返回的仍是《Effective Java》。它根本没有访问 Redis。

这不是 bug,而是本地缓存的必然结果:每个实例的 Caffeine 是独立的,写路径只更新当前实例和共享 Redis,没有通知其他实例。B 的旧值会一直服务到本地 TTL(5 分钟)过期,或者 B 收到一条失效消息。分布式缓存的「分布式」指的是共享存储,不是自动一致。

这一节要做三件事:把 Redis 层的 key、TTL、序列化和超时讲清楚;把故障降级的行为讲清楚;把「B 什么时候能看到新值」讲清楚,并给出三种可选方案。

Redis 的位置:共享缓存,不是事实来源

libraryd 的读路径是三层:

Caffeine(进程内) → Redis(进程间共享) → PostgreSQL(事实来源)

三层的职责不同:

层作用域速度一致性典型失效方式
Caffeine单个 JVM纳秒级最弱,可能旧 5 分钟TTL、本地失效
Redis所有实例共享亚毫秒到毫秒中等,写入后其他实例可见TTL、显式删除
PostgreSQL持久化事实来源毫秒到几十毫秒最强事务、约束

Redis 不是数据库。它没有事务回滚、没有外键、没有持久化保证(取决于配置),不能作为借阅记录的唯一存储。RedisBookCache 的注释写得很明确:best-effort,任何连接或协议错误都会禁用它,服务继续从 Caffeine 和 PostgreSQL 提供。

这个定位决定了几个设计选择:

  • Redis 写失败不回滚数据库。数据库已经提交,Redis 只是缓存;下次读会回源并重新填充。
  • Redis 读失败返回 Optional.empty(),让上层继续查数据库,而不是抛异常中断请求。
  • Redis 的数据可以丢。丢了最多让缓存命中率下降,不能影响业务正确性。
  • Redis 的 TTL 必须存在,否则 key 会永久驻留,和数据库逐渐偏离。

如果业务要求「Redis 里的值必须和数据库强一致」,那就不应该把它当缓存,而应该考虑数据库副本、变更数据捕获(CDC)或分布式事务。缓存的设计前提是「允许短期不一致」。

key、TTL 与序列化:libraryd:book: 的约定

RedisBookCache 的常量很少,但每一个都影响线上行为:

private static final String PREFIX = "libraryd:book:";
private static final long TTL_SECONDS = 300;

key 的格式是 libraryd:book:{isbn},例如:

libraryd:book:9787111213826

前缀承担三个作用:标明应用(libraryd)、标明数据类型(book)、避免和其他服务或同库的其他用途冲突。多环境共用一个 Redis 时,前缀还要加环境,例如 libraryd:prod:book: 和 libraryd:staging:book:,否则测试数据会污染生产缓存。

TTL 是 300 秒。本机通过应用写入一条图书后,用 redis-cli 查看:

TTL libraryd:book:9787111213826
290
GET libraryd:book:9787111213826
{"isbn":"9787111213826","title":"Effective Java","author":"Joshua Bloch","year":2018}

TTL 返回 290,是因为写入后已经过了约 10 秒;-1 表示没有过期时间,-2 表示 key 不存在。值是一个 JSON 对象,由 JsonSupport.objectMapper() 序列化 Book record 得到。

序列化看起来简单,实际上是分布式缓存最容易出事的地方。JsonSupport 的配置是:

ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS);
mapper.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
return mapper;

FAIL_ON_UNKNOWN_PROPERTIES 意味着:如果 Redis 里有一条新版本写入的 JSON,包含当前代码不认识的字段,反序列化会失败。反过来,如果旧 JSON 缺少当前 Book 的字段,Jackson 不会报错,而是把缺失字段填成 null 或 0。这比失败更危险,因为错误数据会静默进入业务。

本机把一条旧格式的值写进 Redis:

{"isbn":"isbn-extra","title":"Old Shape"}

再运行 RedisSchemaDemo,输出是:

first get=Optional[Book[isbn=isbn-extra, title=Old Shape, author=null, year=0]]
second get=Optional[Book[isbn=isbn-extra, title=Old Shape, author=null, year=0]]

author=null、year=0 被当成合法 Book 返回。如果前端或业务逻辑没有校验,就会展示空作者、零年份。缓存没有报错,问题被推迟到用户界面。

如果类型不兼容,例如 year 写成字符串:

{"isbn":"isbn-extra","title":"Old Shape","author":"A","year":"not-a-number"}

反序列化会抛异常,RedisBookCache 捕获后禁用整个 Redis 层:

WARN com.plume.library.RedisBookCache - redis cache disabled after failed read: Cannot deserialize value of type `int` from String "not-a-number": not a valid `int` value (through reference chain: com.plume.library.domain.Book["year"])
first get=Optional.empty
second get=Optional.empty

这是本机输出。注意两个后果:第一,一条坏数据会让整个实例的 Redis 缓存被禁用,不只是这一个 key;第二,第二次读在 0 毫秒内返回空,因为它不再尝试 Redis,直接走数据库。坏 key 本身还留在 Redis 里,TTL 仍是 299,直到自然过期。

这两组输出说明:缓存值的 schema 必须当成契约管理。可选的做法:

  • 加版本前缀:libraryd:book:v1:。Book 结构变化时改成 v2,旧 key 自然过期,不会和新代码混用。
  • 反序列化后校验:author 非空、year 在合理范围,不合法就当 miss 处理,并记一条警告。
  • 保留旧字段兼容:新增字段给默认值,不要删除旧字段;FAIL_ON_UNKNOWN_PROPERTIES 会让「新写旧读」直接失败,滚动发布期间尤其要注意。
  • 灰度发布:先升级读路径,兼容新旧两种 schema,再升级写路径。

连接与超时:300ms 的取舍

RedisBookCache.connect 负责解析 URL、建池、PING 验证:

HostAndPort address = parse(redisUrl);
DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder()
        .connectionTimeoutMillis(300)
        .socketTimeoutMillis(300)
        .build();
JedisPool pool = new JedisPool(address, clientConfig);
try (Jedis jedis = pool.getResource()) {
    jedis.ping();
    log.info("redis cache connected at {}", address);
    return new RedisBookCache(pool, mapper);
} catch (RuntimeException failure) {
    log.warn("redis cache unavailable at {} - continuing with local cache only: {}",
            address, failure.getMessage());
    pool.close();
    return new RedisBookCache(null, mapper);
}

connectionTimeoutMillis(300) 限制建立 TCP 连接的时间,socketTimeoutMillis(300) 限制读写的等待时间。两个都设 300 毫秒,是为了保护请求延迟:Redis 是加速层,不能因为它慢而把整个请求拖住。300 毫秒还没响应,就当成不可用,回源数据库。

300 毫秒是不是太激进,取决于部署:

  • 同机或同可用区,Redis 的 p99 通常在 1 毫秒以内,300 毫秒很宽松。
  • 跨可用区或跨地域,网络抖动可能到几十毫秒,300 毫秒仍然够用,但遇到网络故障时会快速失败。
  • 如果 Redis 本身在高负载下偶尔超过 300 毫秒,缓存会被频繁禁用,命中率反而下降。此时应该先查 Redis 的慢查询和连接数,而不是盲目调大超时。

超时的目标是「快速失败,保护主流程」,不是「尽量等到结果」。把超时调到 5 秒,Redis 故障时每个请求都要等 5 秒,比不用缓存还糟。

JedisPool 在 Jedis 8 里被标记为 deprecated,但参考工程仍然使用它,并在类上加了 @SuppressWarnings("deprecation")。原因写在注释里:这个版本推荐的替代品没有暴露能安全归还资源的连接池。不要因为看到 deprecated 就贸然换成 RedisClient;先确认新 API 的连接生命周期、超时和池化行为,否则容易引入连接泄漏。

parse 方法允许 redis://host:port 或裸 host:port,缺省端口 6379,缺省主机 127.0.0.1。生产环境应该显式配置 LIBRARYD_REDIS_URL,不要依赖缺省值;缺省值只适合本机演示。

故障降级:连接失败与运行期超时

Redis 故障有两种时机,行为不同。

第一种是启动时连不上。connect 里的 ping() 失败,捕获异常,关闭池,返回一个 pool == null 的实例。后续 get 直接返回 Optional.empty(),put 和 invalidate 直接返回,不抛异常。本机把 URL 指向一个没有服务的端口:

WARN com.plume.library.RedisBookCache - redis cache unavailable at 127.0.0.1:1 - continuing with local cache only: Failed to connect to 127.0.0.1:1.
get after failed connect=Optional.empty

第二种是运行期超时。连接已经建立,Redis 突然变慢或网络中断。本机用 docker pause 暂停 Redis,运行 RedisRuntimeFailureDemo:

connected and cached
WARN com.plume.library.RedisBookCache - redis cache disabled after failed read: java.net.SocketTimeoutException: Read timed out
get while redis paused=Optional.empty
first get took 610 ms
second get=Optional.empty
second get took 0 ms

第一次读花了 610 毫秒,比 300 毫秒的 socket timeout 多,因为还包含连接池借出、重试和异常处理的开销;具体数字会随环境变化,但量级说明它在等超时。捕获异常后,available 从 true 变成 false,打印一次警告。第二次读 0 毫秒返回空,因为 get 开头的 if (!available.get()) 直接短路,不再访问 Redis。

disable 的实现是:

private void disable(String operation, Exception failure) {
    if (available.compareAndSet(true, false)) {
        log.warn("redis cache disabled after failed {}: {}", operation, failure.getMessage());
    }
}

compareAndSet(true, false) 保证多个线程同时失败时只打印一条警告。这个设计避免了故障期间日志刷屏,但也带来一个重要行为:Redis 不会自动恢复。available 一旦变成 false,没有任何代码把它设回 true;即使 Redis 在 1 秒后恢复,这个实例也会一直走本地缓存和数据库,直到进程重启。

这是一个有意的取舍:自动重连需要探测、退避、半开状态,做不好会在 Redis 抖动时反复超时;直接禁用最简单,代价是缓存命中率下降。生产环境如果要求自动恢复,需要额外实现健康探测和 available.compareAndSet(false, true),并且限制探测频率,避免每个请求都去试连。

无论哪种降级,调用方都不需要 catch 异常。get 返回空、put 和 invalidate 静默跳过,CachingBookRepository 会继续走数据库。可用性优先于缓存命中率,这是 best-effort 缓存的正确姿势。

写路径与失效:先写库,再动缓存

CachingBookRepository 的写路径在 14.1 已经出现过,这里只保留与 Redis 相关的部分:

@Override
public Book save(Book book) {
    Book saved = delegate.save(book);
    local.put(saved.isbn(), saved);
    redis.put(saved);
    return saved;
}

@Override
public Book update(Book book) {
    Book updated = delegate.update(book);
    local.put(updated.isbn(), updated);
    redis.put(updated);
    return updated;
}

@Override
public boolean deleteByIsbn(String isbn) {
    boolean deleted = delegate.deleteByIsbn(isbn);
    if (deleted) {
        local.invalidate(isbn);
        redis.invalidate(isbn);
    }
    return deleted;
}

顺序是「数据库 → 本地 → Redis」。为什么不是先写 Redis?

  • 数据库是事实来源。先写 Redis,数据库失败时,缓存里会出现数据库没有的值;读请求命中缓存,看到一条不存在的图书。
  • 先写数据库,失败会抛异常,缓存保持旧值。旧值至少对应一个真实存在过的版本。
  • 数据库成功后,本地和 Redis 的更新即使失败,也只是缓存旧值,下一次读会回源修正。

redis.put 内部用 SET ... EX 300:

jedis.set(PREFIX + book.isbn(), mapper.writeValueAsString(book),
        redis.clients.jedis.params.SetParams.setParams().ex(TTL_SECONDS));

SET 带过期时间,避免 key 永久驻留。不要先 SET 再 EXPIRE,两条命令之间进程崩溃会留下无过期时间的 key;SET EX 是原子的。

删除时只在 deleted == true 时失效 Redis。和 14.1 的边界一样:如果数据库已经不存在这条记录,但 Redis 还有旧值(例如外部工具删了库),deleted 返回 false,Redis 不会清理,旧值会服务到 TTL 过期。对一致性要求高的场景,可以在 false 时也 redis.invalidate(isbn),用一次多余的删除换确定性。

写路径的并发窗口仍然存在:A 和 B 同时更新同一本书,数据库按顺序提交,但缓存写入的顺序可能反过来。最后缓存里可能是先提交的旧值,直到 TTL 过期。要彻底解决,需要版本号或「删除而不是更新」加短 TTL。对图书元数据这种低冲突数据,5 分钟窗口通常可以接受;对库存、余额,不能只靠这个模型。

一致性取舍:两个实例、三种方案

回到开头的 CacheStaleDemo:A 更新后,B 的本地缓存仍然是旧值。这是本地缓存的固有属性,不是 Redis 的问题。可选方案有三种,复杂度和一致性依次上升。

方案一:接受最终一致,缩短本地 TTL。

最简单。把本地 expireAfterWrite 从 5 分钟降到 30 秒,B 最多 30 秒后看到新值。代价是本地命中率下降,更多请求落到 Redis;Redis 的 QPS 上升,但通常仍远低于数据库。适合图书元数据、商品详情这类「短暂旧值可接受」的数据。如果业务能接受 5 分钟,就不需要改;先问清楚「用户看到 5 分钟前的标题」是否真的不可接受。

方案二:Redis Pub/Sub 失效广播。

A 更新成功后,向频道 libraryd:cache:invalidate 发布一条消息:

book:9787111213826

所有实例订阅这个频道,收到消息后执行 local.invalidate(isbn)。B 的本地缓存被清掉,下一次读会从 Redis 拿到新值。延迟通常在毫秒到秒级,比 TTL 快得多。

但 Pub/Sub 是「发后即忘」:Redis 不持久化消息,订阅者断线期间的消息会丢失;网络分区时,B 可能收不到失效通知。要工程化,需要配合:

  • 重连后做一次全量或增量对账,不能假设消息一定到达。
  • 消息带版本号,B 只接受比本地更新的版本,避免乱序把新值覆盖成旧值。
  • 发布放在数据库提交之后,不要在事务提交前发布;否则 B 可能先失效、再读到未提交的旧值。
  • 监控发布失败和订阅积压,Pub/Sub 没有 ACK,失败只能靠对账发现。

方案三:版本号或逻辑时钟。

给 Book 加 version 字段,数据库每次更新递增。缓存值里带版本号,本地缓存命中时比较版本;如果本地版本落后,回源 Redis 或数据库。也可以在 Redis 里存一个全局版本号,本地缓存定期拉取。版本号能解决乱序和丢消息,但需要改表结构、改写入路径、改所有读路径,成本最高。

三种方案的取舍:

方案旧值窗口额外复杂度适用数据
短 TTL秒到分钟低元数据、详情页
Pub/Sub 失效毫秒到秒,可能丢消息中大多数共享缓存
版本号取决于对账频率高金额、库存、权限

没有一种方案能同时做到「零旧值、低延迟、低复杂度」。先明确业务能接受多旧的读,再选方案。对 libraryd 的图书标题,短 TTL 加 Pub/Sub 已经足够;借阅状态和库存不应该依赖本地缓存做最终判断。

跨实例击穿、锁与常见错误

Caffeine 的单飞只在一个 JVM 内生效。4 个实例同时发现同一个热点 key 过期,会有 4 个线程分别查数据库。实例越多,回源放大越明显。Redis 分布式锁可以把回源收敛到一个实例:

String lockKey = "libraryd:lock:book:" + isbn;
String token = UUID.randomUUID().toString();
String locked = jedis.set(lockKey, token, SetParams.setParams().nx().px(3000));
if ("OK".equals(locked)) {
    try {
        // 双重检查:拿到锁后先看 Redis 是否已被其他实例填充
        Optional<Book> cached = redis.get(isbn);
        if (cached.isPresent()) {
            return cached;
        }
        Optional<Book> loaded = delegate.findByIsbn(isbn);
        loaded.ifPresent(redis::put);
        return loaded;
    } finally {
        jedis.eval(
                "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                "return redis.call('del', KEYS[1]) else return 0 end",
                List.of(lockKey), List.of(token));
    }
}
return redis.get(isbn); // 没抢到锁,读一次缓存;仍为空则回源或短暂等待

这段代码在本机用 Jedis 8.0.2 编译通过。几个细节:

  • SET key token NX PX 3000 是原子加锁,NX 保证只有第一个成功,PX 防止持有者崩溃后死锁。
  • 释放锁必须比较 token,再删除。直接 DEL 可能删掉别人后来获得的锁。Lua 脚本让「比较 + 删除」原子执行。
  • 拿到锁后要双重检查缓存:在等锁期间,前一个持有者可能已经填充了 Redis。
  • 锁的 TTL 要大于加载耗时。加载 5 秒、锁 3 秒,锁会提前过期,另一个实例进来重复加载。可以按 p99 加载时间设 TTL,或加看门狗续期。
  • 没抢到锁的请求不要空转。可以读一次 Redis,空则短暂等待后重试,或直接回源(牺牲一点放大)。无限重试会把 Redis 打满。
  • 锁只保护「回源」这一段,不保护数据库事务。数据库本身的并发控制(唯一约束、行锁、乐观锁)仍然必要。

常见错误清单:

现象根因处理
缓存里出现数据库没有的书先写 Redis 后写数据库,或写库失败未回滚永远先写数据库,成功后再写缓存
Redis 一条坏数据后命中率归零反序列化异常触发全局 disableschema 版本前缀、反序列化后校验、灰度发布
Redis 恢复后仍不走缓存available 不会自动重置加健康探测和半开恢复,或接受重启恢复
两个实例长期不一致本地 TTL 太长,无失效广播缩短 TTL、Pub/Sub、版本号
Redis 超时拖慢请求超时设得过大,或未降级300ms 级别超时,失败返回空并禁用
热点 key 过期后数据库尖峰单飞只在 JVM 内,实例数放大回源Redis 锁 + 双重检查,或逻辑过期
key 永久不过期只 SET 没设 TTL,或先 SET 后 EXPIRE 中断用 SET ... EX 原子设置,监控无 TTL 的 key
多环境互相污染前缀没有环境标识libraryd:{env}:book:,禁止共用 Redis 库

边界:Redis 不是强一致存储。 主从异步复制时,主节点写入成功、从节点还没同步就故障,可能丢最近的数据。缓存可以接受这种损失,账本不能。不要把 Redis 当唯一数据源,也不要用它实现分布式锁以外的强一致协调(Redlock 本身也有争议)。

边界:本地缓存和 Redis 的 TTL 要对齐。 本地 5 分钟、Redis 300 秒,如果同时写入,两者几乎同时过期,可能同时 miss,形成一个小雪崩。给两层都加抖动,或者让本地 TTL 短于 Redis,避免同时失效。

小结

  • Redis 是共享缓存,不是事实来源;写失败不回滚数据库,读失败返回空并继续回源。
  • key 用 libraryd:book:{isbn},TTL 300 秒,值用 JSON;schema 变化要用版本前缀或兼容字段,否则旧值会静默变成 null/0,类型不兼容会禁用整个 Redis 层。
  • Jedis 的连接和读写超时都是 300 毫秒,目的是快速失败、保护请求延迟;JedisPool 虽被标记 deprecated,但在当前版本仍是能安全归还资源的选择。
  • 启动连不上时返回仅本地缓存的实例;运行期超时会用 AtomicBoolean 禁用 Redis,只打印一次警告,且不会自动恢复。
  • 写路径必须先数据库、再本地、再 Redis;删除只在数据库确认删除后失效缓存,边界情况可以多失效一次换确定性。
  • 两个实例的本地缓存天然不一致;短 TTL、Pub/Sub 失效广播、版本号是三种取舍,按业务能接受的旧值窗口选择。
  • 跨实例击穿需要 Redis 锁或逻辑过期;锁要带 token、过期时间和 Lua 释放,且不能替代数据库自身的并发控制。
  • 缓存的价值是用一致性换延迟;先明确「能接受多旧」,再决定缓存什么、缓存多久、怎么失效。

到这里,缓存从单机到分布式的主线走完了:Caffeine 解决进程内重复查询,Cache-Aside 和负缓存处理穿透、击穿、雪崩,Redis 解决跨实例共享,一致性靠 TTL、广播或版本号收敛。下一章进入测试,先看测试金字塔如何分配单元、集成和端到端测试的比例。

阅读导航:上一节:14.2 缓存模式:Cache-Aside、穿透与雪崩 · 下一节:15.1 测试金字塔:单元、集成与端到端 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发