Canal + Outbox、Kafka 选型与高可用、Caffeine 底层原理总结

Canal + Outbox、Kafka 选型与高可用、Caffeine 底层原理总结

本文整理了一组后端面试中高频出现的工程问题,主要包括四部分:Canal 底层原理与 Outbox 模式、MQ 选型及为什么选择 Kafka、Kafka 高可用机制、Caffeine 本地缓存底层实现与核心源码。整体目标不是背概念,而是把这些组件放到真实业务链路中讲清楚。


一、Canal 底层原理

Canal 的核心思想很简单:伪装成 MySQL 从库,订阅主库 binlog,把数据库变更解析成结构化事件,再交给下游消费

MySQL 主从复制中,从库会向主库发送 binlog dump 请求,主库将 binlog 推送给从库。Canal 就是模拟了这个从库协议,因此它可以像从库一样监听 MySQL 的变更。

整体链路如下:

text 复制代码
业务服务
  ↓
MySQL
  ↓ binlog
Canal Server
  ↓
Canal Client / Adapter
  ↓
MQ / ES / Redis / 搜索系统 / 数据仓库

当业务服务执行一条 SQL:

sql 复制代码
UPDATE user SET name = '张三' WHERE id = 1;

MySQL 会将这次变更记录到 binlog 中。Canal 读取 binlog 后,可以解析出库名、表名、操作类型、变更前数据、变更后数据等信息。

示例:

json 复制代码
{
  "database": "test",
  "table": "user",
  "type": "UPDATE",
  "before": {
    "id": 1,
    "name": "李四"
  },
  "after": {
    "id": 1,
    "name": "张三"
  }
}

Canal 通常要求 MySQL binlog 使用 ROW 模式,因为 ROW 模式记录的是每一行数据的变更,能够准确还原 insert、update、delete 的具体内容。

MySQL binlog 常见格式有三种:

text 复制代码
STATEMENT:记录 SQL 语句
ROW:记录每一行变更前后的数据
MIXED:混合模式

在实际使用 Canal 时,更推荐使用 ROW 模式。


二、Canal 的核心组件

Canal 内部大致可以分为四层。

1. EventParser

EventParser 负责连接 MySQL,模拟 slave,拉取 binlog,并解析成 Canal 内部事件。

它是 Canal 监听 MySQL 的入口。

2. EventSink

EventSink 负责事件过滤、路由和聚合。

例如只监听某个库某张表:

text 复制代码
order_db.order

那么 Canal 可以过滤掉其他表的变更,避免下游处理无关事件。

3. EventStore

EventStore 负责暂存 binlog 事件。

Canal Server 解析到事件后,不一定马上被客户端消费,因此会先将事件放到内部存储中。默认可以理解为一个内存队列。

4. Canal Client / Adapter

Canal Client 可以主动拉取事件,然后由业务代码自行处理。

Canal Adapter 更偏配置化,通常用于将 MySQL 数据同步到 ES、Kafka、RocketMQ 等系统。


三、Canal 的消费模型

Canal 的消费不是简单地"推给你就完事",它有类似 MQ 的确认机制。

典型流程如下:

text 复制代码
client.getWithoutAck(batchSize)
client 处理数据
处理成功 → client.ack(batchId)
处理失败 → client.rollback(batchId)

示例代码:

java 复制代码
Message message = connector.getWithoutAck(batchSize);
long batchId = message.getId();

try {
    // 处理 binlog 事件
    connector.ack(batchId);
} catch (Exception e) {
    connector.rollback(batchId);
}

这可以避免客户端处理失败后事件直接丢失。

但要注意,Canal 能保证失败后可以重新拉到事件,不代表业务处理天然幂等。幂等仍然需要业务自己实现。


四、Outbox 模式解决什么问题

Outbox 模式主要解决一个经典问题:业务数据库写成功了,但 MQ 消息发送失败了怎么办?

例如下单服务:

text 复制代码
1. 插入订单表
2. 发送订单创建消息到 MQ

如果订单写成功,但 MQ 发送失败,就会出现:

text 复制代码
数据库里有订单
但下游系统不知道这个订单已经创建

这是本地事务和消息发送无法原子化的问题。

Outbox 的解决方式是:不要在业务事务里直接发 MQ,而是在同一个数据库事务里写一张消息表。

示例:

sql 复制代码
BEGIN;

INSERT INTO orders(id, user_id, amount, status)
VALUES (1001, 10, 99.00, 'CREATED');

INSERT INTO outbox_event(
  id,
  event_id,
  aggregate_type,
  aggregate_id,
  event_type,
  payload,
  status,
  created_at
)
VALUES (
  1,
  'evt_001',
  'ORDER',
  '1001',
  'ORDER_CREATED',
  '{"orderId":1001,"userId":10,"amount":99.00}',
  'NEW',
  NOW()
);

COMMIT;

这样业务数据和事件记录在同一个本地事务中提交,要么一起成功,要么一起失败。


五、Canal + Outbox 如何配合

Canal 和 Outbox 配合时,通常不是监听所有业务表,而是重点监听 outbox_event 表。

整体链路如下:

text 复制代码
业务服务
  ↓ 同一个本地事务
业务表 + outbox_event 表
  ↓ binlog
Canal
  ↓ 解析 outbox_event 的 INSERT
Outbox Relay
  ↓
MQ
  ↓
下游服务消费事件

业务服务只负责:

text 复制代码
写业务数据
写 outbox_event

Canal 负责:

text 复制代码
监听 outbox_event 表新增记录
解析 binlog
把事件交给 Outbox Relay 或 MQ

下游服务负责:

text 复制代码
消费 MQ 消息
执行业务逻辑
做好幂等

这种方式的关键是:

text 复制代码
业务数据和事件记录由本地事务保证一致性
Canal 只监听已经提交的 binlog
MQ 负责异步分发
消费者通过 event_id 做幂等

六、为什么不直接监听业务表

Canal 可以直接监听业务表,比如监听 orders 表的 update。但是这并不总是好方案。

因为数据库变更不等于业务事件。

例如:

sql 复制代码
UPDATE orders SET status = 'PAID' WHERE id = 1001;

这条 SQL 可能表示:

text 复制代码
订单支付成功
后台人工修复状态
补偿任务重试
测试人员手动改库

光看数据库变更,很难准确判断业务语义。

而 Outbox 的优势是:业务代码明确写出事件语义。

例如:

json 复制代码
{
  "eventType": "ORDER_PAID",
  "orderId": 1001,
  "paidAt": "2026-05-03T10:00:00Z"
}

这比 Canal 对着业务表 update 事件猜业务含义更可靠。


七、Outbox 表设计

一个常见的 outbox_event 表可以这样设计:

sql 复制代码
CREATE TABLE outbox_event (
  id BIGINT PRIMARY KEY,
  event_id VARCHAR(64) NOT NULL UNIQUE,
  aggregate_type VARCHAR(64) NOT NULL,
  aggregate_id VARCHAR(64) NOT NULL,
  event_type VARCHAR(128) NOT NULL,
  payload JSON NOT NULL,
  status VARCHAR(32) DEFAULT 'NEW',
  retry_count INT DEFAULT 0,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL
);

字段说明:

text 复制代码
event_id:全局唯一事件 ID,用于幂等
aggregate_type:聚合类型,比如 ORDER、USER、PAYMENT
aggregate_id:聚合 ID,比如 orderId
event_type:事件类型,比如 ORDER_CREATED、ORDER_PAID
payload:事件内容
status:事件状态,可选
retry_count:重试次数,可选
created_at / updated_at:时间字段

如果完全依赖 Canal 监听 binlog,status 字段可以不更新。

如果还需要补偿任务扫描未成功投递的事件,则可以使用 statusretry_countnext_retry_at 等字段。


八、Outbox Relay 发送 MQ 的逻辑

Canal Client 监听到 outbox_event 表的 insert 后,通常做以下几步:

text 复制代码
1. 解析 binlog event
2. 判断是否是 outbox_event 表
3. 判断操作类型是否是 INSERT
4. 取出 event_id、event_type、payload
5. 发送到 MQ
6. MQ 发送成功后 ack Canal batch

伪代码:

java 复制代码
Message message = connector.getWithoutAck(100);

try {
    for (CanalEntry.Entry entry : message.getEntries()) {
        if (!isRowData(entry)) {
            continue;
        }

        CanalEntry.RowChange rowChange =
            CanalEntry.RowChange.parseFrom(entry.getStoreValue());

        for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
            if (rowChange.getEventType() == CanalEntry.EventType.INSERT
                    && isOutboxTable(entry)) {

                Map<String, String> columns = toMap(rowData.getAfterColumnsList());

                String eventId = columns.get("event_id");
                String eventType = columns.get("event_type");
                String payload = columns.get("payload");

                mqProducer.send(eventType, eventId, payload);
            }
        }
    }

    connector.ack(message.getId());
} catch (Exception e) {
    connector.rollback(message.getId());
}

关键点是:发送 MQ 成功后再 ack Canal。

如果 MQ 发送失败,Canal 不 ack,之后还能重新消费。


九、Canal + Outbox 的可靠性边界

Canal + Outbox 通常能保证:

text 复制代码
业务数据和事件记录原子提交
Canal 能从 binlog 捕获事件
MQ 发送失败可以重试
下游最终可以收到事件

但它不能天然保证:

text 复制代码
消息只发送一次
下游只消费一次
事件绝对不会重复
事件全局严格有序

工程上通常采用:

text 复制代码
至少一次投递 + 幂等消费

幂等是必须做的。

消费者可以建一张消费记录表:

sql 复制代码
CREATE TABLE consumed_event (
  event_id VARCHAR(64) NOT NULL,
  consumer_name VARCHAR(128) NOT NULL,
  consumed_at DATETIME NOT NULL,
  PRIMARY KEY(event_id, consumer_name)
);

消费逻辑:

text 复制代码
1. 收到消息
2. 用 event_id + consumer_name 插入 consumed_event
3. 插入成功,说明第一次消费,继续执行业务
4. 插入失败,说明重复消息,直接忽略

伪代码:

java 复制代码
try {
    insertConsumedEvent(eventId, consumerName);
    doBusiness(payload);
} catch (DuplicateKeyException e) {
    // 已消费,忽略
}

更严谨的做法是:insertConsumedEventdoBusiness 放在同一个本地事务里。


十、MQ 选型要看什么

在 Canal + Outbox 场景中,MQ 承接的是 outbox_event 事件。

MQ 需要解决的问题包括:

text 复制代码
解耦:订单服务不直接调用库存、积分、搜索等服务
削峰:高峰期事件先进入 MQ,下游按能力消费
广播:同一个事件可以被多个下游系统消费
恢复:消费者挂了之后可以继续消费
回放:下游系统出错后可以重新处理历史事件
顺序:同一个订单、用户、支付单内的事件保持有序

常见 MQ 对比:

维度 Kafka RocketMQ RabbitMQ
核心定位 分布式事件流 / 日志型 MQ 业务消息 MQ 传统队列 / 路由型 MQ
吞吐能力 很强 中等
消息保留 天然适合长期保留和回放 支持保留,但核心不是事件日志 更偏队列消费
多消费者订阅 很适合 适合 适合,但大规模事件流不如 Kafka 自然
顺序消息 分区内有序 队列 / MessageGroup 有序 单队列 FIFO,扩展后复杂
延迟消息 原生不是强项 强项 可实现,但不是最自然
事务消息 不适合替代 Outbox 支持事务消息 支持确认机制
回放能力 很强 一般 传统队列弱
适合场景 事件流、日志、CDC、数据同步 订单交易、延迟消息、事务消息 任务队列、复杂路由、轻量异步

十一、为什么 Canal + Outbox 更适合 Kafka

Kafka 的核心模型是 append-only log,也就是追加写日志。

而 Canal + Outbox 链路中的几个关键组件,本质上都是日志模型:

text 复制代码
MySQL binlog:数据库提交日志
Outbox table:业务事件日志
Kafka topic:分布式事件日志

因此 Kafka 和 Canal + Outbox 的模型非常一致。

选择 Kafka 的核心原因包括以下几点。

1. 高吞吐

Canal 监听 binlog 后,高峰期可能产生大量事件。Kafka 的 partition 模型非常适合高吞吐写入和并行消费。

一个 topic 可以拆成多个 partition:

text 复制代码
topic: order-event
  partition-0
  partition-1
  partition-2
  partition-3

生产者可以并行写入多个 partition,消费者也可以通过 consumer group 并行消费。

2. 可回放

Kafka 的消费者通过 offset 记录消费进度,消息不会因为某个消费者读了就立即删除,而是按照保留策略保存一段时间。

这意味着可以重置 offset 重新消费历史事件。

典型场景:

text 复制代码
新系统上线,需要补历史数据
某个消费者逻辑有 bug,需要重放
ES / Redis / 数仓数据坏了,需要重建
风控规则升级,需要回看历史事件

这是 Kafka 相比传统队列的重要优势。

3. 多消费者组独立消费

同一个事件可能被多个系统消费。

例如:

text 复制代码
Topic: order-event

consumer group: inventory-service
consumer group: point-service
consumer group: coupon-service
consumer group: search-index-service
consumer group: data-warehouse-service

每个 consumer group 都有自己的 offset,互不影响。

库存服务消费慢了,不影响积分服务;搜索服务挂了,也不影响数仓消费。

4. 按业务 key 局部有序

很多业务不需要全局有序,只需要同一个聚合内有序。

例如同一个订单的事件:

text 复制代码
ORDER_CREATED
ORDER_PAID
ORDER_SHIPPED

Kafka 可以使用 message key 控制分区。

例如:

text 复制代码
key = orderId

这样同一个 orderId 的消息会进入同一个 partition,而 Kafka 保证单个 partition 内有序。

5. CDC 和数据同步生态成熟

Kafka 常用于 CDC、日志采集、流处理、实时数仓等场景,后续接 Flink、Spark、ClickHouse、ES、数据湖等系统更自然。


十二、为什么不一定选择 RocketMQ 或 RabbitMQ

RocketMQ 很适合强业务消息场景,例如:

text 复制代码
订单交易消息
延迟消息
事务消息
顺序消息
消费重试和死信

如果业务强依赖延迟消息,比如"订单 30 分钟未支付自动取消",并且团队已有 RocketMQ 运维经验,那么 RocketMQ 是很合理的选择。

但在 Canal + Outbox 场景中,本地事务与消息一致性已经由 Outbox 解决,不需要把 RocketMQ 的事务消息作为核心卖点。

RabbitMQ 更适合:

text 复制代码
传统任务队列
复杂 routing
低到中等吞吐异步任务
RPC-like 异步调用

但它不天然适合大规模事件流、长期保留和历史回放。

因此可以简单总结:

text 复制代码
复杂路由 / 工作队列 → RabbitMQ
延迟消息 / 事务消息 / 电商业务消息 → RocketMQ
事件流 / CDC / 回放 / 高吞吐 → Kafka

在 Canal + Outbox 场景下,选择 Kafka 的核心原因是:它更像事件日志系统,而不是单纯的消息队列。


十三、Kafka 消息设计建议

Outbox Relay 发送 Kafka 时可以这样设计:

text 复制代码
topic: order-event
key: aggregate_id
value: payload
headers:
  event_id
  event_type
  aggregate_type
  aggregate_id
  occurred_at

示例消息:

json 复制代码
{
  "eventId": "evt_001",
  "eventType": "ORDER_PAID",
  "aggregateType": "ORDER",
  "aggregateId": "1001",
  "payload": {
    "orderId": 1001,
    "userId": 10,
    "amount": 99.00,
    "paidAt": "2026-05-03T10:00:00Z"
  }
}

Kafka 发送时:

text 复制代码
topic = order-event
key = 1001

这样同一个订单的事件会进入同一个 partition,保证该订单维度下的顺序。


十四、Kafka 高可用机制总览

Kafka 高可用的核心是:

text 复制代码
多副本复制
Leader / Follower 架构
ISR 机制
Controller 故障转移
Producer 重试与幂等
Consumer Group Rebalance
Offset 恢复

Kafka 高可用主要围绕以下几个对象:

text 复制代码
Broker 高可用:某台 Kafka 节点挂了,集群还能工作
Partition 高可用:某个分区 Leader 挂了,Follower 能接管
数据高可用:消息不能因为单机故障就丢
消费高可用:消费者挂了,其他消费者能继续消费
元数据高可用:Controller 挂了,可以重新选举

Kafka 的基本存储单位是 partition。

一个 topic 会拆成多个 partition,每个 partition 可以配置多个副本。

示例:

text 复制代码
partition-0:
  broker-1: leader
  broker-2: follower
  broker-3: follower

客户端读写通常都访问 leader,follower 负责从 leader 同步数据。


十五、Kafka 多副本机制

Kafka 通过副本因子保证数据冗余。

例如:

properties 复制代码
replication.factor=3

表示每个 partition 有 3 个副本。

如果 broker-1 挂了,Kafka 可以从 broker-2 或 broker-3 中选出新的 leader。

Kafka 的高可用不是 topic 级别的魔法,而是 partition 级别的副本冗余。


十六、Leader / Follower 写入流程

消息写入流程大致如下:

text 复制代码
1. Producer 发送消息到 partition leader
2. Leader 写入本地日志
3. Follower 从 leader 拉取消息
4. Follower 写入自己的本地日志
5. Leader 根据 acks 配置决定什么时候响应 producer

这里最关键的是 producer 的 acks 配置。

acks=0

Producer 发出去就不管,不等待 broker 响应。

优点是快,缺点是最容易丢消息。

acks=1

Leader 写入成功就返回成功。

如果 leader 写完但还没同步给 follower 就挂了,这条消息可能丢失。

acks=all

Leader 要等待 ISR 中的副本确认写入后,才返回成功。

生产业务事件通常推荐:

properties 复制代码
acks=all
enable.idempotence=true
retries=Integer.MAX_VALUE

同时配合 topic / broker 侧配置:

properties 复制代码
replication.factor=3
min.insync.replicas=2

含义是:

text 复制代码
每个 partition 有 3 个副本
至少 2 个同步副本在线并确认写入
producer 才认为写入成功

十七、ISR 机制

ISR 全称是 In-Sync Replicas,即同步副本集合。

不是所有 follower 都有资格接管 leader。只有跟 leader 保持足够同步的副本,才会在 ISR 中。

例如:

text 复制代码
partition-0 replicas: broker-1, broker-2, broker-3

leader: broker-1
ISR: broker-1, broker-2
out of sync: broker-3

这表示 broker-3 已经落后太多,不适合作为新 leader。

如果 broker-1 挂了,Kafka 会优先从 ISR 中选择 broker-2 作为新 leader。

ISR 是 Kafka 防止数据丢失的关键机制。


十八、min.insync.replicas 的作用

min.insync.replicas 是高可靠写入中的关键配置。

例如:

properties 复制代码
replication.factor=3
min.insync.replicas=2
acks=all

含义是:

text 复制代码
每条消息至少要写入 2 个 ISR 副本,才算成功。

正常情况:

text 复制代码
broker-1 leader
broker-2 follower
broker-3 follower
ISR = [1,2,3]

写入成功。

如果 broker-3 挂了:

text 复制代码
ISR = [1,2]

仍然可以写入成功。

如果 broker-2 也挂了:

text 复制代码
ISR = [1]

这时 producer 写入会失败,因为 ISR 数量小于 min.insync.replicas=2

这不是 Kafka 不可靠,而是 Kafka 为了保护数据一致性,宁愿拒绝写入,也不假装写入成功。


十九、Leader 故障转移

当 partition leader 所在 broker 挂了,Kafka 会触发 leader election。

原状态:

text 复制代码
partition-0:
  broker-1 leader
  broker-2 follower
  broker-3 follower
ISR = [1,2,3]

broker-1 挂了之后:

text 复制代码
partition-0:
  broker-2 new leader
  broker-3 follower
ISR = [2,3]

流程如下:

text 复制代码
1. broker-1 失联
2. controller 感知 broker 下线
3. controller 为 broker-1 上的 leader partitions 重新选 leader
4. 更新集群元数据
5. producer / consumer 刷新 metadata
6. 客户端连接新 leader 继续工作

整个过程会有短暂不可用,但不会导致整个集群挂掉。


二十、Controller 高可用

Kafka 集群中有一个 controller,负责管理:

text 复制代码
分区 leader 选举
broker 上下线
元数据变更

早期 Kafka 使用 ZooKeeper 管理元数据和 controller 选举。

新版本 Kafka 支持 KRaft 模式,去掉 ZooKeeper,由 Kafka 自己的 controller quorum 管理元数据。

无论是哪种模式,目标都是:

text 复制代码
controller 挂了,可以重新选一个 controller

Controller 不是单点。它挂掉后,集群会重新选举新的 controller。


二十一、Unclean Leader Election

Kafka 有一个很危险但重要的配置:

properties 复制代码
unclean.leader.election.enable

正常情况下,Kafka 只会从 ISR 中选新 leader。

但如果 ISR 中没有可用副本了,Kafka 有两个选择:

text 复制代码
1. 不选 leader,分区暂时不可用,但不丢数据
2. 选择非 ISR 副本当 leader,恢复可用,但可能丢数据

如果开启:

properties 复制代码
unclean.leader.election.enable=true

Kafka 可能选择落后的副本当 leader,提升可用性,但牺牲数据一致性。

如果关闭:

properties 复制代码
unclean.leader.election.enable=false

Kafka 只从 ISR 中选 leader,保护数据不丢,但极端情况下 partition 会不可用。

业务事件系统通常建议:

properties 复制代码
unclean.leader.election.enable=false

二十二、Consumer Group 高可用

Kafka 消费端高可用依赖:

text 复制代码
Consumer Group
Rebalance
Offset

例如库存服务有 3 个消费者实例:

text 复制代码
consumer group: inventory-service

consumer-1 -> partition-0
consumer-2 -> partition-1
consumer-3 -> partition-2

如果 consumer-2 挂了,Kafka 会触发 rebalance:

text 复制代码
consumer-1 -> partition-0
consumer-3 -> partition-1, partition-2

consumer-3 会接管 consumer-2 原来消费的 partition。

消费进度通过 offset 保存,因此消费者挂了之后,新的消费者可以从上次提交的 offset 继续消费。

但要注意:

text 复制代码
offset 提交成功,但业务处理失败 → 消息可能丢处理
业务处理成功,但 offset 提交失败 → 消息可能重复消费

所以消费者仍然需要幂等。

推荐消费顺序:

text 复制代码
1. 拉取消息
2. 开启本地事务
3. 根据 event_id 做幂等校验
4. 执行业务逻辑
5. 提交本地事务
6. 提交 Kafka offset

如果第 6 步失败,消息可能重复消费,但幂等表可以兜住。


二十三、Kafka 推荐配置

业务事件流推荐配置如下。

Topic / Broker:

properties 复制代码
replication.factor=3
min.insync.replicas=2
unclean.leader.election.enable=false
retention.ms=604800000

Producer:

properties 复制代码
acks=all
enable.idempotence=true
retries=Integer.MAX_VALUE
delivery.timeout.ms=120000
request.timeout.ms=30000

Consumer:

properties 复制代码
enable.auto.commit=false
auto.offset.reset=earliest
isolation.level=read_committed

其中:

text 复制代码
enable.auto.commit=false

表示手动提交 offset,避免消息还没处理完 offset 就先提交。

整体原则是:

text 复制代码
宁愿短暂写失败,也不要假装写成功然后丢数据。

二十四、Kafka 高可用总结

Kafka 高可用靠的是:

text 复制代码
partition 多副本
leader / follower 复制
ISR 同步副本集合
acks=all + min.insync.replicas
leader 故障选举
controller 高可用
consumer group rebalance
offset 恢复
producer 重试和幂等

在 Canal + Outbox 场景中:

text 复制代码
Outbox 保证业务数据和事件记录一致
Canal 保证捕获已提交事件
Kafka 保证消息通道高可用
Consumer 幂等保证重复消息不可怕

Kafka 不负责解决所有问题。它负责 MQ 层高可用,业务一致性还需要 Outbox 和消费者幂等来兜底。


二十五、Caffeine 是什么

Caffeine 是 Java 中非常常用的高性能本地缓存库。

它不是简单的:

text 复制代码
ConcurrentHashMap + 定时删除

而是:

text 复制代码
ConcurrentHashMap 存数据
+
Node 存元信息
+
读写缓冲降低锁竞争
+
W-TinyLFU 淘汰算法提高命中率
+
过期队列 / 时间轮处理 TTL
+
维护任务批量执行淘汰、过期、引用清理

一句话:Caffeine 用 ConcurrentHashMap 保证并发读写,用 W-TinyLFU 决定谁该被淘汰。

常见用法:

java 复制代码
Cache<String, User> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(5))
    .build();

二十六、Caffeine 整体结构

Caffeine 底层大致结构如下:

text 复制代码
Caffeine Builder
   ↓
LocalCache
   ↓
BoundedLocalCache / UnboundedLocalCache
   ↓
ConcurrentHashMap<K, Node<K,V>>
   ↓
Node 保存 value、weight、访问时间、写入时间、队列指针

如果配置了 maximumSizeexpireAfterWriteexpireAfterAccess 等限制,一般会走 BoundedLocalCache

核心字段可以简化理解为:

java 复制代码
final ConcurrentHashMap<Object, Node<K, V>> data;
final Buffer<Node<K, V>> readBuffer;
final MpscGrowableArrayQueue<Runnable> writeBuffer;
final ReentrantLock evictionLock;
final Weigher<K, V> weigher;

重点是:Caffeine 不是自己重新实现一个 HashMap,而是基于 ConcurrentHashMap,再叠加缓存淘汰、过期和统计策略。


二十七、Caffeine 读流程

cache.getIfPresent(key) 的简化流程:

text 复制代码
1. 从 ConcurrentHashMap 中查 Node
2. 如果不存在,返回 null
3. 如果存在,检查是否过期
4. 如果没过期,返回 value
5. 顺手记录一次访问,用于后续调整淘汰队列

传统 LRU 每次读都要移动链表节点,这会导致大量锁竞争。

Caffeine 的做法是:读操作先写入 readBuffer,不马上改淘汰队列。之后批量 drain buffer,再统一调整队列。

所以读路径是:

text 复制代码
读 Map:依赖 ConcurrentHashMap
记录访问:写 readBuffer
真正调整淘汰队列:异步 / 批量 / 摊销执行

并且 readBuffer 是可以有损的。

读记录丢失只会略微影响命中率,不影响数据正确性。


二十八、Caffeine 写流程

cache.put(key, value) 的简化流程:

text 复制代码
1. 创建或更新 Node
2. 写入 ConcurrentHashMap
3. 把 AddTask / UpdateTask / RemovalTask 放入 writeBuffer
4. 触发 maintenance
5. maintenance 批量处理队列、过期、淘汰

写事件不能像读事件一样随便丢,因为写入、更新、删除会影响容量、权重、过期队列和淘汰队列。

维护流程可以简化为:

java 复制代码
void maintenance(@Nullable Runnable task) {
    drainReadBuffer();
    drainWriteBuffer();

    if (task != null) {
        task.run();
    }

    drainKeyReferences();
    drainValueReferences();
    expireEntries();
    evictEntries();
    climb();
}

这段流程负责:

text 复制代码
处理读缓冲
处理写缓冲
清理弱引用 / 软引用
处理过期
处理容量淘汰
动态调整窗口大小

二十九、Caffeine 核心淘汰算法:W-TinyLFU

Caffeine 使用的不是普通 LRU,而是 Window TinyLFU,简称 W-TinyLFU

它结合了两种思想:

text 复制代码
Recency:最近访问过的数据可能还会被访问
Frequency:历史上经常访问的数据更值得保留

普通 LRU 只看最近访问,很容易被突发冷数据污染。

例如缓存容量为 1000,突然来了 1000 个只访问一次的冷数据,LRU 可能会把原来的热点数据全部挤出去。

W-TinyLFU 的结构如下:

text 复制代码
Cache
├── Window LRU
│   └── 新数据先放这里
│
└── Main SLRU
    ├── Probation
    │   └── 候选观察区
    └── Protected
        └── 高频保护区

新数据先进入 Window 区,相当于试用期。

Window 满了之后,最老的数据会作为 candidate 进入 Main 区竞争。

Main 区中会选出一个 victim,然后使用 TinyLFU 比较 candidate 和 victim 的历史访问频率。

text 复制代码
candidateFreq > victimFreq
    保留 candidate,淘汰 victim

candidateFreq <= victimFreq
    淘汰 candidate,保留 victim

三十、Window 区的作用

新写入的数据会先进 Window 区。

为什么需要 Window?

因为如果只看历史频率,新数据刚进来时频率一定低,可能永远没有机会留下。

Window 给新数据一个试用期:

text 复制代码
新 entry → Window
Window 满了 → 挤出最老的 candidate
candidate 和 Main victim 比较
谁频率低,谁出局

源码中新增节点的逻辑可以简化为:

java 复制代码
setWeightedSize(weightedSize() + weight);
setWindowWeightedSize(windowWeightedSize() + weight);

if (evicts()) {
    accessOrderWindowDeque().offerLast(node);
}

含义是:新节点先进入 window 队列。


三十一、Main 区:Probation + Protected

Main 区分为两部分:

text 复制代码
Probation:观察区
Protected:保护区

从 Window 被挤出来的 candidate,会进入 Main 的 probation。

如果 probation 中的 entry 再次被访问,就会晋升到 protected。

访问逻辑可以简化为:

java 复制代码
void onAccess(Node node) {
    frequencySketch().increment(keyRef);

    if (node.inWindow()) {
        reorder(windowDeque, node);
    } else if (node.inMainProbation()) {
        reorderProbation(node);
    } else {
        reorder(protectedDeque, node);
    }
}

含义:

text 复制代码
访问 window 节点 → window 内重排
访问 probation 节点 → 晋升 protected
访问 protected 节点 → protected 内重排

reorderProbation 可以简化为:

java 复制代码
accessOrderProbationDeque().remove(node);
accessOrderProtectedDeque().offerLast(node);
node.makeMainProtected();

也就是:probation 命中一次,就进入 protected。

如果 protected 满了,最老的 protected entry 会降级回 probation。


三十二、FrequencySketch 频率估算

Caffeine 没有给每个 key 存精确访问次数。

因为精确计数会带来:

text 复制代码
内存成本高
并发更新开销大
老热点永久霸榜

Caffeine 使用的是 FrequencySketch,本质上是 4-bit CountMinSketch。

每个 counter 最大值是 15。

frequency(key) 会查 4 个 counter,取最小值:

java 复制代码
int frequency(Object e) {
    int frequency = Integer.MAX_VALUE;

    for (int i = 0; i < 4; i++) {
        int count = counterAt(e, i);
        frequency = Math.min(frequency, count);
    }

    return frequency;
}

为什么取最小值?

因为 CountMinSketch 存在哈希冲突,某个 counter 可能被多个 key 共用,导致频率被高估。取多个 counter 的最小值,可以降低高估误差。

访问一次时,会对 4 个 counter 增加:

java 复制代码
public void increment(Object e) {
    boolean added =
        incrementAt(slot0, index0)
      | incrementAt(slot1, index1)
      | incrementAt(slot2, index2)
      | incrementAt(slot3, index3);

    if (added && (++size == sampleSize)) {
        reset();
    }
}

当采样次数达到阈值,会执行 reset(),把所有 counter 减半:

java 复制代码
void reset() {
    for (int i = 0; i < table.length; i++) {
        table[i] = (table[i] >>> 1) & RESET_MASK;
    }
}

这就是频率 aging,用来防止老热点永久占据缓存。


三十三、Caffeine 过期机制

Caffeine 支持多种过期策略:

text 复制代码
expireAfterWrite:写入后一段时间过期
expireAfterAccess:访问后一段时间过期
expireAfter:自定义每个 entry 的过期时间
refreshAfterWrite:写入后一段时间刷新

底层实现:

text 复制代码
expireAfterAccess → access-order queue
expireAfterWrite  → write-order queue
variable expire   → TimerWheel

固定过期时间可以使用队列,因为队列头部最老。

例如 expireAfterWrite(5min)

text 复制代码
writeOrderDeque 头部是最早写入的 entry
检查头部是否过期
如果头部没过期,后面的更不可能过期

过期判断可以简化为:

java 复制代码
boolean hasExpired(Node node, long now, V value) {
    return
        expiresAfterAccess()
            && (now - node.getAccessTime() >= expiresAfterAccessNanos())
        |
        expiresAfterWrite()
            && (now - node.getWriteTime() >= expiresAfterWriteNanos())
        |
        expiresVariable()
            && (now - node.getVariableTime() >= 0);
}

三十四、refreshAfterWrite 和 expireAfterWrite 区别

expireAfterWriterefreshAfterWrite 很容易混淆。

text 复制代码
expireAfterWrite:过期后读到的是 miss,需要重新 load
refreshAfterWrite:过了刷新时间后,第一次读仍然返回旧值,同时异步刷新

示例:

java 复制代码
LoadingCache<String, User> cache = Caffeine.newBuilder()
    .refreshAfterWrite(Duration.ofMinutes(1))
    .expireAfterWrite(Duration.ofMinutes(5))
    .build(this::loadUser);

含义:

text 复制代码
1 分钟后触发刷新,但旧值还能返回
5 分钟后如果还没刷新成功,就过期

这适合高并发读热点数据,避免热点 key 过期后大量请求同时回源。


三十五、Caffeine 并发控制

Caffeine 高性能的关键之一是:Map 操作和淘汰策略操作分离。

text 复制代码
ConcurrentHashMap:负责 key-value 并发安全
evictionLock:保护淘汰队列、过期队列、频率统计等策略结构
readBuffer / writeBuffer:将大量策略操作批量化

数据正确性由 ConcurrentHashMap 保证。

淘汰策略不要求每次读写后立刻完全同步,只需要最终一致即可。

缓存淘汰策略不需要纳秒级绝对精确。只要整体命中率高、内存不爆、数据语义正确,就可以接受策略结构的延迟维护。


三十六、Node 的作用

Caffeine 存在 ConcurrentHashMap 中的不是裸 value,而是 Node<K,V>

Node 会保存:

text 复制代码
key
value
weight
queueType
accessTime
writeTime
variableTime
access-order 前后指针
write-order 前后指针
节点状态

Node 同时承担:

text 复制代码
Map value 包装
淘汰队列节点
过期队列节点
状态机载体

在并发场景下,entry 可能有以下状态:

text 复制代码
alive:同时存在于 hash table 和访问 / 写队列中
retired:已经从 hash table 删除,等待从队列移除
dead:两边都不存在,可以被 GC

这个状态机用于处理并发读写删除下的乱序问题。


三十七、Caffeine 重要源码入口

核心源码类包括:

text 复制代码
BoundedLocalCache.java
    核心缓存实现、读写缓冲、淘汰、过期、维护任务

FrequencySketch.java
    TinyLFU 频率估算,4-bit CountMinSketch

BoundedBuffer.java
    读缓冲,striped non-blocking bounded buffer

Node.java
    缓存节点,保存 value、时间、权重、队列指针、状态

TimerWheel.java
    variable expiration 的时间轮实现

LocalCache.java
    本地缓存核心接口

1. BoundedLocalCache 初始化

简化源码:

java 复制代码
protected BoundedLocalCache(Caffeine builder,
        AsyncCacheLoader cacheLoader,
        boolean isAsync) {

    this.data = new ConcurrentHashMap<>(builder.getInitialCapacity());
    this.evictionLock = new ReentrantLock();

    this.readBuffer =
        evicts() || collectKeys() || collectValues() || expiresAfterAccess()
            ? new BoundedBuffer<>()
            : Buffer.disabled();

    this.writeBuffer =
        new MpscGrowableArrayQueue<>(WRITE_BUFFER_MIN, WRITE_BUFFER_MAX);

    this.accessPolicy =
        evicts() || expiresAfterAccess()
            ? this::onAccess
            : e -> {};
}

重点:

text 复制代码
data:真正存数据
evictionLock:保护策略结构
readBuffer:读事件缓冲
writeBuffer:写事件缓冲
accessPolicy:读后执行的策略动作

2. 访问后处理

java 复制代码
void onAccess(Node node) {
    frequencySketch().increment(key);

    if (node.inWindow()) {
        reorder(windowDeque, node);
    } else if (node.inMainProbation()) {
        reorderProbation(node);
    } else {
        reorder(protectedDeque, node);
    }
}

含义:

text 复制代码
访问一次 → 更新频率
Window 命中 → Window LRU 重排
Probation 命中 → 晋升 Protected
Protected 命中 → Protected LRU 重排

3. 淘汰入口

java 复制代码
void evictEntries() {
    if (!evicts()) {
        return;
    }

    Node candidate = evictFromWindow();
    evictFromMain(candidate);
}

含义:

text 复制代码
先从 Window 中挤出 candidate
再在 Main 中比较 candidate 和 victim
最后淘汰更不值得保留的节点

4. Window 到 Main

java 复制代码
Node evictFromWindow() {
    Node node = accessOrderWindowDeque().peekFirst();

    while (windowWeightedSize() > windowMaximum()) {
        node.makeMainProbation();
        accessOrderWindowDeque().remove(node);
        accessOrderProbationDeque().offerLast(node);
        setWindowWeightedSize(windowWeightedSize() - node.getPolicyWeight());
    }

    return firstCandidate;
}

含义:

text 复制代码
Window 超过容量
把 Window 最老节点移到 Main Probation
这个节点成为 candidate

5. TinyLFU 准入判断

java 复制代码
boolean admit(Object candidateKey, Object victimKey) {
    int candidateFreq = frequencySketch().frequency(candidateKey);
    int victimFreq = frequencySketch().frequency(victimKey);

    if (candidateFreq > victimFreq) {
        return true;
    }

    return false;
}

真实源码中还会加入少量随机准入逻辑,用于防止哈希碰撞攻击导致候选项一直无法进入缓存。

6. FrequencySketch 计数

java 复制代码
public int frequency(Object e) {
    int frequency = Integer.MAX_VALUE;

    for (int i = 0; i < 4; i++) {
        int count = counterAt(e, i);
        frequency = Math.min(frequency, count);
    }

    return frequency;
}
java 复制代码
public void increment(Object e) {
    boolean added =
        incrementAt(slot0, index0)
      | incrementAt(slot1, index1)
      | incrementAt(slot2, index2)
      | incrementAt(slot3, index3);

    if (added && (++size == sampleSize)) {
        reset();
    }
}
java 复制代码
void reset() {
    for (int i = 0; i < table.length; i++) {
        table[i] = (table[i] >>> 1) & RESET_MASK;
    }
}

分别对应:

text 复制代码
frequency:估算频率
increment:访问后增加频率
reset:周期性衰减历史频率

7. 过期判断

java 复制代码
boolean hasExpired(Node node, long now, V value) {
    return
        expiresAfterAccess()
            && (now - node.getAccessTime() >= expiresAfterAccessNanos())
        |
        expiresAfterWrite()
            && (now - node.getWriteTime() >= expiresAfterWriteNanos())
        |
        expiresVariable()
            && (now - node.getVariableTime() >= 0);
}

8. 维护任务

java 复制代码
void maintenance(Runnable task) {
    drainReadBuffer();
    drainWriteBuffer();

    drainKeyReferences();
    drainValueReferences();

    expireEntries();
    evictEntries();
    climb();
}

维护任务做的是:

text 复制代码
处理访问记录
处理写入记录
处理弱引用 / 软引用
处理过期
处理容量淘汰
调整 Window / Main 比例

三十八、Caffeine 面试总结版

可以这样回答:

Caffeine 底层主要由 ConcurrentHashMap + Node + W-TinyLFU + Buffer + Maintenance 组成。真正的数据存储在 ConcurrentHashMap 中,value 被封装成 Node,Node 记录访问时间、写入时间、权重、队列指针和状态。

为了避免每次读都加锁调整 LRU 队列,Caffeine 会把读事件写入有损的 readBuffer,把写事件写入可靠的 writeBuffer,然后通过 maintenance 批量 drain,摊销锁竞争。

淘汰算法不是普通 LRU,而是 Window TinyLFU。新数据先进入 Window LRU,Window 满后候选进入 Main Probation,并和 Main 中的 victim 通过 FrequencySketch 比较历史访问频率。候选频率更高则淘汰 victim,否则淘汰候选。

Main 又分为 Probation 和 Protected。Probation 命中后晋升 Protected,从而保护真正热点。

FrequencySketch 使用 4-bit CountMinSketch 估算访问频率,并周期性 reset 做 aging,防止老热点永久占据缓存。

过期方面,expireAfterAccess 使用访问顺序队列,expireAfterWrite 使用写入顺序队列,自定义过期使用 TimerWheel。过期和淘汰都不是每次操作同步全量执行,而是在写后和部分读后触发维护任务,摊销 O(1)。

一句话总结:

text 复制代码
Caffeine = ConcurrentHashMap 保证并发存取
         + Buffer 批量记录读写
         + W-TinyLFU 决定淘汰
         + FrequencySketch 估算热度
         + 队列 / 时间轮处理过期
         + maintenance 摊销维护成本

三十九、整篇总结

这一整套链路可以串起来理解。

业务服务不直接发 MQ,而是在本地事务中同时写业务表和 outbox_event 表。Outbox 保证业务数据和事件记录的一致性。

Canal 模拟 MySQL 从库读取 binlog,只监听已经提交的 outbox_event 记录,并把事件交给 Outbox Relay。

Outbox Relay 将事件发送到 Kafka。Kafka 作为事件流系统,提供高吞吐、多消费者组、可回放、分区内有序和高可用能力。

消费者从 Kafka 中消费事件,通过 event_id 做幂等,保证重复消息不会导致业务重复执行。

如果系统内部还需要本地缓存,Caffeine 可以作为高性能 JVM 进程内缓存。它通过 ConcurrentHashMap 保证并发读写,通过 W-TinyLFU 提高命中率,通过读写缓冲和维护任务降低锁竞争。

最终可以形成一条比较完整的后端工程链路:

text 复制代码
业务服务
  ↓ 本地事务
MySQL 业务表 + outbox_event
  ↓ binlog
Canal
  ↓
Outbox Relay
  ↓
Kafka
  ↓
下游消费者
  ↓
幂等处理 / 本地缓存 / 数据同步

这套架构的核心思想是:

text 复制代码
Outbox 解决本地事务和事件记录一致性
Canal 解决事件捕获
Kafka 解决事件流传输和高可用
消费者幂等解决重复消息
Caffeine 解决本地热点数据快速访问

面试中不要只背"Canal 监听 binlog""Kafka 高吞吐""Caffeine 用 W-TinyLFU"。更重要的是讲清楚每一层解决什么问题,以及它不解决什么问题。

相关推荐
Ting-yu1 小时前
SpringCloud快速入门(11)---- Sentinel(异常处理)
java·spring boot·后端·spring·spring cloud·sentinel
X56611 小时前
什么是Bootstrap的移动优先响应式设计
jvm·数据库·python
m0_470857641 小时前
实现一个可精确定位、支持左右移动与删除的文本光标系统
jvm·数据库·python
m0_591364731 小时前
mysql如何通过索引减少行锁范围_mysql索引与加锁逻辑
jvm·数据库·python
许长安1 小时前
Kafka 架构讲解:从提交日志到分区副本机制
c++·经验分享·笔记·分布式·架构·kafka
代码中介商1 小时前
MySQL 核心进阶:事务、隔离级别与视图实战
数据库·mysql
七爷不在我这里1 小时前
oracle的26版本及以下 Null的判断及空串判定
数据库·oracle
qq_297574671 小时前
第十二篇:RabbitMQ消息积压问题——排查与解决方案(实战优化)
分布式·rabbitmq
qq_297574671 小时前
第十三篇:RabbitMQ限流与熔断——保护系统稳定性
分布式·rabbitmq·ruby