概述
应用读多写少时,常在 PostgreSQL 与 Redis 之间加一层缓存:热数据放 Redis,持久化仍由 PostgreSQL 负责。难点不在「能不能缓存」,而在写路径与读路径如何协调——数据库已更新,缓存仍是旧值;或缓存已失效,并发读把脏数据又写回缓存。
下文以 Redis 作缓存、PostgreSQL 作权威数据源,梳理几种主流缓存模式、各自的一致性特征与适用场景。示例以伪代码和常见实践为主,便于对照落地。
模式总览
| 模式 | 谁加载缓存 | 谁写缓存 | 一致性 | 复杂度 | 典型场景 |
|---|---|---|---|---|---|
| Cache-Aside | 应用 | 应用 | 最终一致,需自行处理竞态 | 低 | 通用业务,最常用 |
| Read-Through | 缓存层 | 缓存层 | 同 Cache-Aside | 中 | 统一缓存 SDK |
| Write-Through | 应用 | 缓存层同步写 DB | 较强一致 | 中 | 读多、可接受写延迟 |
| Write-Back | 应用 | 缓存层异步刷盘 | 弱一致,有丢数据风险 | 高 | 写密集、可容忍短暂不一致 |
| Binlog 同步 | 中间件 | CDC 消费 binlog 更新缓存 | 最终一致,解耦业务 | 高 | 多服务共享缓存、不想侵入业务 |
| 分布式事务 | 应用 + 协调者 | 2PC / TCC / Saga | 强一致或业务级一致 | 很高 | 资金、库存等强一致域 |
graph LR
App[应用]
Redis[(Redis)]
PG[(PostgreSQL)]
App -->|Cache-Aside / Read-Through / Write-Through| Redis
App -->|直接写或经缓存层| PG
Redis -->|miss 时回源| PG
PG -->|Binlog / WAL| CDC[CDC 消费者]
CDC -->|更新/失效| Redis
Cache-Aside(旁路缓存)
也叫 Lazy Loading。应用自己管缓存:读先查 Redis,miss 再查 PostgreSQL 并回填;写先更新 PostgreSQL,再删除(推荐)或更新 Redis。
读路径
sequenceDiagram
participant App as 应用
participant Redis as Redis
participant PG as PostgreSQL
App->>Redis: GET user:1001
alt 命中
Redis-->>App: 返回缓存
else 未命中
Redis-->>App: nil
App->>PG: SELECT * FROM users WHERE id = 1001
PG-->>App: 行数据
App->>Redis: SET user:1001 EX 3600
App-->>App: 返回数据
end
写路径(推荐:先写库再删缓存)
void updateUser(User user) {
// 1. 先更新权威数据源
jdbcTemplate.update(
"UPDATE users SET name = ?, updated_at = now() WHERE id = ?",
user.getName(), user.getId());
// 2. 再删除缓存(而非更新缓存,避免并发写乱序)
redisTemplate.delete("user:" + user.getId());
}
为什么删缓存而不是改缓存? 见下节。
删缓存 vs 更新缓存
Cache-Aside 写路径的默认做法是:先更新 PostgreSQL,再删除 Redis key,而不是 SET 新值。PostgreSQL 是权威数据源,缓存只是副本;删 key 后由下次读 miss 回源加载,逻辑简单,也更容易收敛到一致状态。
并发下「更新缓存」的风险
直接 SET 新值在并发读写时,容易出现旧值覆盖新值,脏数据可能长期留在 Redis:
T1 线程 A:UPDATE PostgreSQL(name → "bob")
T2 线程 B:读 miss,从 PG 读到旧值 "alice"
T3 线程 A:SET Redis = "bob"
T4 线程 B:SET Redis = "alice" ← 旧值覆盖新值
删缓存把问题收窄:最坏情况是删完之后、下次读之前,有个读把已提交的旧库数据回填——窗口通常很短,且配合 TTL 能自愈。
写库与删缓存的顺序
| 顺序 | 风险 |
|---|---|
| 先删缓存,再写库 | 写库完成前,读可能回源并把旧库数据写回 Redis,脏窗口较大 |
| 先写库,再删缓存 | 删缓存前可能有读回填旧值,但窗口通常更小;业界默认选这个 |
先写库再删缓存也不是强一致;要求更高时可配合 延迟双删、分布式锁或 CDC 补偿。
何时可以考虑更新缓存
不是绝对禁止 SET,而是有条件的优化,而非默认推荐:
| 场景 | 删缓存 | 更新缓存 |
|---|---|---|
| 通用 CRUD、多字段、有并发写 | 推荐 | 不推荐 |
| 写路径有分布式锁 / 单写者 | 可以 | 可考虑 |
值可算、无乱序(如 INCR 计数) |
可以 | 更适合 |
| 刚写完就要极低延迟读到新值 | 需多一次读 | 可考虑,失败要有补偿 |
| 复杂对象、多表聚合 | 更安全 | 易漏字段 |
结论:默认删缓存;只有能管住写顺序、避免并发覆盖,且更新失败有补偿时,才考虑写后 SET。
一致性问题
| 问题 | 原因 | 缓解 |
|---|---|---|
| 脏读 | 删缓存与写库顺序不当 | 先写库再删缓存;延迟双删 |
| 缓存击穿 | 热点 key 失效瞬间大量回源 | 互斥锁、逻辑过期 |
| 不一致长期存在 | 删缓存失败 | 重试、CDC 补偿 |
Cache-Aside 灵活、侵入小,是互联网业务默认选项;一致性责任在应用代码。
延迟双删
「先写库、再删缓存」在删缓存前后,仍可能有并发读把旧数据写回 Redis:
T1 线程 A:UPDATE PostgreSQL(成功)
T2 线程 B:读 miss,从 PG 读到旧值
T3 线程 B:SET Redis = 旧值 ← 脏数据回填
T4 线程 A:DEL Redis
若 T4 发生在 T3 之前,第一次删除无效,Redis 里会长期留着旧值。
延迟双删:写库后删两次缓存,中间加一段延迟,第二次删除用于清掉延迟期间被回填的脏数据。
1. UPDATE PostgreSQL
2. DEL Redis ← 第一次删
3. sleep / 异步延迟(如 500ms~1s)
4. DEL Redis ← 第二次删
void updateUser(User user) {
jdbcTemplate.update(
"UPDATE users SET name = ?, updated_at = now() WHERE id = ?",
user.getName(), user.getId());
redis.delete("user:" + user.getId()); // 第一次
asyncExecutor.schedule(() -> {
redis.delete("user:" + user.getId()); // 延迟后再删一次
}, 500, TimeUnit.MILLISECONDS);
}
延迟应大于「一次读 miss → 查库 → 回填 Redis」的典型耗时,否则第二次删仍可能早于脏回填。
| 优点 | 缺点 |
|---|---|
| 实现简单,能收窄不一致窗口 | 仍非强一致;延迟难调;多一次删操作 |
适合对一致性要求比「只删一次」略高、但还不想上分布式事务的场景。
Read-Through(读穿透)
对应用而言,缓存像一个黑盒数据源:应用只调 cache.get(key),未命中时由缓存组件自己去 PostgreSQL 加载并写入 Redis,应用不直接访问数据库。
sequenceDiagram
participant App as 应用
participant Cache as 缓存层
participant Redis as Redis
participant PG as PostgreSQL
App->>Cache: get("user:1001")
Cache->>Redis: GET user:1001
alt miss
Redis-->>Cache: nil
Cache->>PG: SELECT ...
PG-->>Cache: row
Cache->>Redis: SET user:1001
end
Cache-->>App: User
Java 里 Guava LoadingCache、Spring Cache 的 CacheLoader,或自研 CacheService 封装「读 Redis → miss 读 PG → 回填」,都是 Read-Through 形态。
public User getUser(long id) {
return userCache.get(id, key -> {
return userRepository.findById(key).orElse(null);
});
}
与 Cache-Aside 的实质差别:加载逻辑收口在缓存层,业务代码更干净。一致性策略仍与 Cache-Aside 相同——写路径一般仍是应用写 PostgreSQL 后删缓存,Read-Through 只管读。
Write-Through(写穿透)
应用在写时只面对缓存层;缓存层同步完成两件事:更新 Redis,并立即写 PostgreSQL。读仍可直接走 Redis。
sequenceDiagram
participant App as 应用
participant Cache as 缓存层
participant Redis as Redis
participant PG as PostgreSQL
App->>Cache: put("user:1001", user)
Cache->>Redis: SET user:1001
Cache->>PG: UPDATE users SET ...
Cache-->>App: OK
public void saveUser(User user) {
cacheWriter.write("user:" + user.getId(), user, () -> {
userRepository.save(user); // 同步落库
});
}
- 优点:读路径简单,缓存与库在写完成时对齐。
- 缺点:写延迟 = Redis + PostgreSQL;写放大;缓存层故障会挡住写路径。
- 适用:读远多于写、可接受同步双写延迟的场景。
若 Redis 写成功、PostgreSQL 失败,需缓存层做回滚或标记不一致,否则会出现「缓存新、库旧」。
Write-Back(写回 / Write-Behind)
应用写时只写 Redis(或标记为 dirty),由缓存层异步批量刷入 PostgreSQL。读优先走 Redis。
sequenceDiagram
participant App as 应用
participant Redis as Redis
participant Flusher as 异步刷盘
participant PG as PostgreSQL
App->>Redis: SET user:1001 (dirty)
App-->>App: 写完成(快)
Note over Flusher: 合并、批量、定时
Flusher->>Redis: 取 dirty 条目
Flusher->>PG: BATCH UPDATE / UPSERT
Flusher->>Redis: 清除 dirty 标记
# 简化思路:Redis Hash 存数据 + dirty 标记,后台协程批量 UPSERT
HSET user:1001 name "alice" version 3 dirty 1
# flusher 每 N 秒或累积 M 条后:
INSERT INTO users ... ON CONFLICT (id) DO UPDATE ...
HDEL user:1001 dirty
- 优点:写吞吐高,可合并写、削峰。
- 缺点:Redis 宕机且未刷盘会丢数据;读到其他副本时可能看不到最新写;实现复杂(重试、幂等、顺序)。
- 适用:计数器、浏览量、日志缓冲等可丢或可重建的数据;不适合账户余额、订单状态。
与 Write-Through 对比:Write-Through 追求「写完即可信」,Write-Back 追求「写完尽快返回」。
Binlog 同步(CDC)
业务代码只写 PostgreSQL,不直接维护 Redis。通过 逻辑复制 / WAL / Debezium 等消费 PostgreSQL 变更(类似 MySQL binlog),由独立消费者更新或删除 Redis。
sequenceDiagram
participant App as 应用
participant PG as PostgreSQL
participant WAL as WAL / 逻辑复制
participant CDC as Debezium / 自研消费者
participant Redis as Redis
App->>PG: UPDATE users SET name = 'bob' WHERE id = 1001
PG->>WAL: 写入变更记录
WAL->>CDC: 推送变更事件
CDC->>Redis: DEL user:1001 或 SET 新值
Debezium 是什么
Debezium 是开源的 CDC(Change Data Capture) 平台,由 Red Hat 发起,现属 Apache 基金会。它监听数据库变更日志(PostgreSQL 的 WAL、MySQL 的 binlog 等),把「哪张表、哪一行、改了什么」转成事件流,供下游消费。
典型链路:
PostgreSQL(业务写入)
↓ WAL / 逻辑复制
Debezium Connector
↓
Kafka / Kafka Connect(常见部署方式)
↓
消费者(删 Redis、写 ES、发通知……)
| 概念 | 含义 |
|---|---|
| CDC | 捕获数据库变更的一类技术 |
| WAL / binlog | 数据库自带的变更日志 |
| Debezium | 实现 CDC 的具体框架,支持 PostgreSQL、MySQL、MongoDB 等 |
一次 UPDATE 可能被编成类似事件(简化):
{
"op": "u",
"before": { "id": 1001, "name": "alice" },
"after": { "id": 1001, "name": "bob" }
}
op 取值:c 创建、u 更新、d 删除。消费者根据主键执行 DEL user:1001 等操作。
Debezium 不是缓存组件,而是旁路监听器:PostgreSQL 一变,就通知下游修正 Redis;也可同步 Elasticsearch、数据仓库等,避免业务代码里散落「改库 + 删缓存 + 发消息」。
PostgreSQL 侧通常开启逻辑复制槽,配合 Debezium PostgreSQL Connector:
{
"name": "users-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.dbname": "app",
"table.include.list": "public.users",
"plugin.name": "pgoutput"
}
}
消费者伪逻辑:
void onUserChange(ChangeEvent event) {
long id = event.getKey();
if (event.isDelete()) {
redis.delete("user:" + id);
} else {
redis.delete("user:" + id); // 删缓存,下次读回源(稳妥)
// 或直接 SET:需保证事件顺序与全字段
}
}
- 优点:业务与缓存解耦;多服务写同一库时缓存仍可由统一管道维护;可做审计、搜索索引同步。
- 缺点:最终一致,通常有毫秒~秒级延迟;需处理消息乱序、重复消费(幂等);运维组件多。
- 适用:微服务多写源、希望业务代码零缓存逻辑、可接受短暂不一致。
CDC 补偿
应用侧「写库后删缓存」是主路径,但可能失败或根本没人删:
| 情况 | 后果 |
|---|---|
PostgreSQL 写成功,Redis DEL 失败 |
缓存长期是旧值 |
| 多个服务写同一库,有的服务忘了删缓存 | 同上 |
| 运维直接改库、脚本 bulk update | 应用层从未删缓存 |
CDC 补偿:CDC 管道作为第二道保险,根据库里的真实变更再去删或更新 Redis——即使应用删缓存失败,只要变更被消费到,缓存仍会被修正。
主路径: 应用写 PG → 应用 DEL Redis(可能失败)
补偿路径:应用写 PG → WAL 变更 → Debezium 等 → DEL Redis
CDC 不一定是唯一的缓存维护方式;常与 Cache-Aside 组合——业务仍用 Cache-Aside 读写,写路径尽量删缓存,CDC 负责兜底。
与 延迟双删 对比:
| 延迟双删 | CDC 补偿 | |
|---|---|---|
| 触发方 | 应用自己 | 独立 CDC 消费者 |
| 针对问题 | 删缓存之后又被脏回填 | 删缓存根本没成功(或没人删) |
| 一致性 | 最终一致,窗口更短 | 最终一致,有管道延迟 |
| 复杂度 | 低 | 中高(多一套基础设施) |
实践中可组合:先写库再删缓存 + 延迟双删收紧竞态 + CDC 补偿兜底删缓存失败。
分布式事务
当「写 PostgreSQL」与「写/删 Redis」必须原子或可补偿时,单机双写不够,需要分布式事务或等价的业务方案。
两阶段提交(2PC)
协调者先 prepare 各参与者,再 commit。Redis 非 XA 参与者,原生 2PC 不直接适用;实践中多用事务消息或TCC 代替朴素 2PC。
TCC(Try-Confirm-Cancel)
Try: 预留资源 — 如 Redis 加锁 / 写临时 key,DB 冻结库存
Confirm: 确认提交 — 删缓存、扣减库存、删临时 key
Cancel: 回滚 — 释放预留
适合库存、优惠券等需要明确业务语义的场景,代码侵入大,但一致性强于「先写库再删缓存」。
Saga
长事务拆成本地事务链,每步有补偿操作。例如:订单服务写 PostgreSQL 成功 → 发消息 → 库存服务扣减 → 失败则发补偿消息回滚订单。
sequenceDiagram
participant Order as 订单服务
participant PG as PostgreSQL
participant MQ as 消息队列
participant Stock as 库存服务
participant Redis as Redis
Order->>PG: 创建订单
Order->>MQ: OrderCreated
Stock->>Redis: DECR stock:sku1
Stock->>PG: UPDATE inventory
alt 失败
Stock->>MQ: Compensate
Order->>PG: 取消订单
end
Redis 事务与 Lua
Redis 的 MULTI/EXEC 或 Lua 脚本只能保证单 Redis 实例内原子性,不能与 PostgreSQL 单机事务跨资源原子提交。常见折中:
- 以 PostgreSQL 为权威:事务提交后再删 Redis;失败则靠 CDC 或重试队列补偿。
- Canal / 事务消息:本地消息表与业务同事务写入,异步删缓存。
- 强一致域:干脆不缓存该数据,或只缓存只读副本。
| 方案 | 一致性 | 性能 | 复杂度 |
|---|---|---|---|
| 先写库再删缓存 | 最终一致 | 高 | 低 |
| 延迟双删 + 消息补偿 | 最终一致(更紧) | 中高 | 中 |
| TCC / Saga | 业务级强一致 | 中 | 高 |
| 2PC(跨 DB) | 强一致 | 低 | 很高 |
如何选择
默认起点 → Cache-Aside(读)+ 写库后删缓存(写)
读逻辑想收口 → Read-Through 包装
写也要简单一致 → Write-Through(接受写延迟)
写多读少、可丢 → Write-Back(慎用)
多服务、解耦 → PostgreSQL CDC → Redis
资金/库存 → 分布式事务或干脆不缓存写路径
无论哪种模式,都建议:
- 给缓存 key 设合理 TTL,即使逻辑漏删也能自愈。
- 对热点 key 使用互斥回填或逻辑过期,避免击穿。
- 监控 缓存命中率、CDC 延迟、删缓存失败率。
- 在文档里写清本接口是强一致还是最终一致,避免上下游假设错误。