缓存一致性详解
本章导读
缓存一致性是分布式系统中最棘手的问题之一。当数据同时存在于缓存和数据库时,如何保证两者的一致性?本章将深入分析 Cache Aside、延时双删、Canal 监听 binlog 等主流方案,帮助你做出正确的技术选型。
学习目标:
- 目标1:理解缓存一致性问题的根源,掌握不同一致性等级的适用场景
- 目标2:掌握 Cache Aside 模式的读写流程,理解"删除优于更新"的设计思想
- 目标3:能够实现延时双删和 Canal binlog 监听方案,保证最终一致性
前置知识:已完成《缓存设计详解》的学习,理解缓存穿透、雪崩、击穿问题
阅读时长:约 35 分钟
一、知识概述
缓存一致性是分布式系统中经典且棘手的问题。当数据同时存在于缓存(如Redis)和数据库(如MySQL)时,如何保证两者的数据一致性?这个问题在高并发场景下尤为复杂。
本文将深入分析缓存一致性的各种方案,包括:
- 缓存更新策略对比
- 双写一致性问题的根源
- 主流解决方案及其优缺点
- 延时双删方案
- Canal监听binlog方案
- 最终一致性的工程实践
二、问题根源分析
2.1 为什么会有一致性问题?
markdown
┌─────────┐ 读/写 ┌─────────┐
│ 应用 │ ──────────────→│ 缓存 │
└─────────┘ └─────────┘
│
│ 写
↓
┌─────────┐
│ 数据库 │
└─────────┘
问题场景:
场景1:先更新缓存,再更新数据库
makefile
Thread1: 更新缓存(A→B)
Thread2: 更新缓存(A→C)
Thread2: 更新数据库(A→C)
Thread1: 更新数据库(A→B) ← 数据库最终是B,缓存是C,不一致!
场景2:先更新数据库,再更新缓存
makefile
Thread1: 更新数据库(A→B)
Thread2: 更新数据库(A→C)
Thread2: 更新缓存(A→C)
Thread1: 更新缓存(A→B) ← 缓存最终是B,数据库是C,不一致!
场景3:先删除缓存,再更新数据库
makefile
Thread1: 删除缓存(A)
Thread2: 读缓存miss,读数据库(A)
Thread1: 更新数据库(A→B)
Thread2: 写缓存(A) ← 缓存又被写回旧值!
2.2 一致性等级
| 等级 | 说明 | 适用场景 |
|---|---|---|
| 强一致性 | 写后读一定能读到最新值 | 银行转账、库存扣减 |
| 最终一致性 | 一定时间后达到一致 | 社交动态、商品信息 |
| 弱一致性 | 不保证最终一致 | 新闻阅读数、点赞数 |
三、缓存更新策略
3.1 四种策略对比
| 策略 | 并发问题 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 更新缓存 + 更新数据库 | 有 | 差 | 低 | 不推荐 |
| 更新数据库 + 更新缓存 | 有 | 差 | 低 | 不推荐 |
| 删除缓存 + 更新数据库 | 有 | 中 | 中 | 读少写多 |
| 更新数据库 + 删除缓存 | 少 | 好 | 中 | 推荐 |
3.2 为什么删除优于更新?
java
// 更新缓存的问题
public void update(String key, Object value) {
db.update(key, value); // 数据库更新
cache.set(key, value); // 缓存更新
// 问题:如果value计算复杂,每次更新都重算浪费资源
// 问题:并发时可能覆盖其他线程的更新
}
// 删除缓存的优势
public void update(String key, Object value) {
db.update(key, value); // 数据库更新
cache.delete(key); // 缓存删除
// 优势:惰性计算,读时再加载
// 优势:避免并发覆盖问题
}
四、主流解决方案
4.1 Cache Aside Pattern(旁路缓存)
读流程:
markdown
1. 先读缓存
2. 缓存命中 → 返回
3. 缓存未命中 → 读数据库 → 写入缓存 → 返回
写流程:
markdown
1. 先更新数据库
2. 再删除缓存
代码示例:
java
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private RedisTemplate<String, User> redisTemplate;
private static final String CACHE_PREFIX = "user:";
/**
* 读取用户信息
*/
public User getUser(Long userId) {
String key = CACHE_PREFIX + userId;
// 1. 先读缓存
User user = redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 2. 缓存未命中,读数据库
user = userMapper.selectById(userId);
// 3. 写入缓存(设置过期时间,防止脏数据)
if (user != null) {
redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
}
return user;
}
/**
* 更新用户信息 - Cache Aside Pattern
*/
@Transactional
public void updateUser(User user) {
// 1. 先更新数据库
userMapper.updateById(user);
// 2. 再删除缓存
String key = CACHE_PREFIX + user.getId();
redisTemplate.delete(key);
}
}
问题:极端并发下仍可能不一致
makefile
Thread1: 读缓存miss
Thread1: 读数据库(旧值)
Thread2: 更新数据库
Thread2: 删除缓存
Thread1: 写缓存(旧值) ← 脏数据产生!
分析:这种概率极低,需要同时满足:
- 缓存刚好失效
- 读请求查询数据库(耗时)
- 写请求更新数据库(更快)
- 写请求删除缓存
- 读请求写入缓存(比写请求慢)
4.2 延时双删策略
核心思想: 删除 → 更新数据库 → 延时后再删除
java
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
@Autowired
private RedisTemplate<String, Product> redisTemplate;
private static final String CACHE_PREFIX = "product:";
private static final long DELAY_MS = 500; // 延时时间
/**
* 延时双删策略
*/
@Transactional
public void updateProduct(Product product) {
String key = CACHE_PREFIX + product.getId();
// 1. 第一次删除缓存
redisTemplate.delete(key);
// 2. 更新数据库
productMapper.updateById(product);
// 3. 延时后再次删除(异步执行)
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(DELAY_MS);
redisTemplate.delete(key);
log.info("延时双删执行完成, key={}", key);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
延时时间设置:
diff
延时时间 > 读请求的耗时 + 几十毫秒缓冲
读请求耗时估算:
- 数据库查询:10-50ms
- 网络传输:5-20ms
- 对象序列化:1-5ms
- 总计:约20-100ms
建议延时时间:200ms-500ms
进阶版:基于消息队列的可靠延时双删
java
@Service
public class ProductSyncService {
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String DELAY_EXCHANGE = "cache.delay.exchange";
private static final String DELAY_ROUTING_KEY = "cache.delete";
/**
* 发送延时删除消息
*/
public void sendDelayDelete(String cacheKey, long delayMs) {
CacheDeleteMessage message = new CacheDeleteMessage(cacheKey, System.currentTimeMillis());
rabbitTemplate.convertAndSend(DELAY_EXCHANGE, DELAY_ROUTING_KEY, message, msg -> {
// 设置延时时间(RabbitMQ延时插件)
msg.getMessageProperties().setDelay((int) delayMs);
return msg;
});
}
/**
* 消费延时删除消息
*/
@RabbitListener(queues = "cache.delete.queue")
public void handleCacheDelete(CacheDeleteMessage message) {
redisTemplate.delete(message.getCacheKey());
log.info("延时删除缓存成功: key={}", message.getCacheKey());
}
}
@Data
@AllArgsConstructor
class CacheDeleteMessage {
private String cacheKey;
private Long timestamp;
}
4.3 基于Canal的binlog监听方案
架构设计:
markdown
┌──────────┐ 写 ┌──────────┐
│ 应用 │ ────────→ │ MySQL │
└──────────┘ └──────────┘
│
│ binlog
↓
┌──────────┐
│ Canal │
└──────────┘
│
│ 解析事件
↓
┌──────────┐
│ Redis │
└──────────┘
Canal配置:
yaml
# canal.properties
canal.serverMode = tcp
canal.destinations = example
# instance.properties
canal.instance.master.address = 127.0.0.1:3306
canal.instance.dbUsername = canal
canal.instance.dbPassword = canal
canal.instance.filter.regex = .*\\..*
Canal客户端实现:
java
@Component
public class CanalClient implements InitializingBean, DisposableBean {
private CanalConnector connector;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Value("${canal.server:127.0.0.1:11111}")
private String canalServer;
@Value("${canal.destination:example}")
private String destination;
private volatile boolean running = true;
@Override
public void afterPropertiesSet() {
// 创建连接
connector = CanalConnectors.newSingleConnector(
new InetSocketAddress(canalServer.split(":")[0],
Integer.parseInt(canalServer.split(":")[1])),
destination, "", ""
);
// 启动消费线程
new Thread(this::consumeBinlog, "canal-consumer").start();
}
private void consumeBinlog() {
while (running) {
try {
connector.connect();
connector.subscribe(".*\\..*");
while (running) {
Message message = connector.getWithoutAck(100);
long batchId = message.getId();
if (batchId != -1 && !message.getEntries().isEmpty()) {
processEntries(message.getEntries());
}
connector.ack(batchId);
}
} catch (Exception e) {
log.error("Canal消费异常", e);
try {
Thread.sleep(1000);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
}
}
private void processEntries(List<CanalEntry.Entry> entries) {
for (CanalEntry.Entry entry : entries) {
if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) {
continue;
}
try {
CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
String tableName = entry.getHeader().getTableName();
CanalEntry.EventType eventType = rowChange.getEventType();
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
processRowChange(tableName, eventType, rowData);
}
} catch (Exception e) {
log.error("解析binlog异常", e);
}
}
}
private void processRowChange(String tableName, CanalEntry.EventType eventType,
CanalEntry.RowData rowData) {
// 根据表名映射缓存key前缀
String cachePrefix = getCachePrefix(tableName);
if (cachePrefix == null) {
return;
}
switch (eventType) {
case INSERT:
case UPDATE:
// 获取主键值
String id = getColumnValue(rowData.getAfterColumnsList(), "id");
String cacheKey = cachePrefix + id;
// 删除缓存,让应用重新加载
redisTemplate.delete(cacheKey);
log.info("缓存删除: key={}, event={}", cacheKey, eventType);
break;
case DELETE:
String deleteId = getColumnValue(rowData.getBeforeColumnsList(), "id");
String deleteKey = cachePrefix + deleteId;
redisTemplate.delete(deleteKey);
log.info("缓存删除: key={}, event=DELETE", deleteKey);
break;
default:
break;
}
}
private String getCachePrefix(String tableName) {
// 表名与缓存key的映射
Map<String, String> mapping = Map.of(
"t_user", "user:",
"t_product", "product:",
"t_order", "order:"
);
return mapping.get(tableName);
}
private String getColumnValue(List<CanalEntry.Column> columns, String columnName) {
return columns.stream()
.filter(col -> col.getName().equals(columnName))
.findFirst()
.map(CanalEntry.Column::getValue)
.orElse(null);
}
@Override
public void destroy() {
running = false;
if (connector != null) {
connector.disconnect();
}
}
}
Canal方案优势:
- 应用代码无需关心缓存删除
- 解耦数据写入和缓存更新
- 保证数据库与缓存的最终一致性
- 支持多种数据库变更场景
4.4 订阅数据库变更(Spring事件)
java
// 事件定义
public class DataChangeEvent extends ApplicationEvent {
private final String tableName;
private final Long id;
private final OperationType operationType;
public DataChangeEvent(Object source, String tableName, Long id, OperationType operationType) {
super(source);
this.tableName = tableName;
this.id = id;
this.operationType = operationType;
}
public enum OperationType {
INSERT, UPDATE, DELETE
}
}
// 事件发布
@Service
public class UserService {
@Autowired
private ApplicationEventPublisher eventPublisher;
@Autowired
private UserMapper userMapper;
@Transactional
public void updateUser(User user) {
userMapper.updateById(user);
// 发布数据变更事件
eventPublisher.publishEvent(
new DataChangeEvent(this, "t_user", user.getId(), OperationType.UPDATE)
);
}
}
// 事件监听 - 异步删除缓存
@Component
public class CacheInvalidationListener {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final Map<String, String> TABLE_CACHE_MAPPING = Map.of(
"t_user", "user:",
"t_product", "product:"
);
@Async
@EventListener
public void handleDataChange(DataChangeEvent event) {
String cachePrefix = TABLE_CACHE_MAPPING.get(event.getTableName());
if (cachePrefix != null) {
String cacheKey = cachePrefix + event.getId();
redisTemplate.delete(cacheKey);
log.info("缓存失效: key={}", cacheKey);
}
}
}
五、最终一致性最佳实践
5.1 完整方案示例
java
@Service
@Slf4j
public class ProductCacheService {
@Autowired
private ProductMapper productMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RabbitTemplate rabbitTemplate;
private static final String CACHE_PREFIX = "product:";
private static final String LOCK_PREFIX = "lock:product:";
private static final long CACHE_EXPIRE_HOURS = 2;
private static final long DELAY_DELETE_MS = 500;
/**
* 查询商品 - 防止缓存穿透
*/
public Product getProduct(Long productId) {
String key = CACHE_PREFIX + productId;
// 1. 查询缓存
Object cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
if (cached instanceof NullValue) {
// 空值标记,防止穿透
return null;
}
return (Product) cached;
}
// 2. 分布式锁防止缓存击穿
String lockKey = LOCK_PREFIX + productId;
String lockValue = UUID.randomUUID().toString();
try {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 3. 查询数据库
Product product = productMapper.selectById(productId);
// 4. 写入缓存
if (product != null) {
redisTemplate.opsForValue().set(
key, product, CACHE_EXPIRE_HOURS, TimeUnit.HOURS
);
} else {
// 空值标记,防止穿透(过期时间短一些)
redisTemplate.opsForValue().set(
key, NullValue.INSTANCE, 5, TimeUnit.MINUTES
);
}
return product;
} finally {
// 释放锁(Lua脚本保证原子性)
releaseLock(lockKey, lockValue);
}
} else {
// 获取锁失败,等待后重试
Thread.sleep(50);
return getProduct(productId); // 递归重试
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取商品信息被中断", e);
}
}
/**
* 更新商品 - 延时双删 + 消息队列
*/
@Transactional
public void updateProduct(Product product) {
String key = CACHE_PREFIX + product.getId();
// 1. 第一次删除缓存
redisTemplate.delete(key);
// 2. 更新数据库
productMapper.updateById(product);
// 3. 发送延时删除消息
sendDelayDeleteMessage(key, DELAY_DELETE_MS);
}
/**
* 发送延时删除消息
*/
private void sendDelayDeleteMessage(String cacheKey, long delayMs) {
Map<String, Object> message = Map.of(
"cacheKey", cacheKey,
"timestamp", System.currentTimeMillis()
);
rabbitTemplate.convertAndSend(
"cache.delay.exchange",
"cache.delete",
message,
msg -> {
msg.getMessageProperties().setDelay((int) delayMs);
return msg;
}
);
}
/**
* 释放分布式锁
*/
private void releaseLock(String lockKey, String lockValue) {
String script = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
lockValue
);
}
/**
* 空值标记(防止缓存穿透)
*/
private enum NullValue {
INSTANCE
}
}
5.2 消息队列配置
java
@Configuration
public class RabbitMQConfig {
// 延时交换机(需要安装rabbitmq_delayed_message_exchange插件)
@Bean
public CustomExchange delayExchange() {
Map<String, Object> args = new HashMap<>();
args.put("x-delayed-type", "direct");
return new CustomExchange(
"cache.delay.exchange",
"x-delayed-message",
true,
false,
args
);
}
// 延时队列
@Bean
public Queue delayQueue() {
return QueueBuilder.durable("cache.delete.queue").build();
}
// 绑定关系
@Bean
public Binding delayBinding() {
return BindingBuilder.bind(delayQueue())
.to(delayExchange())
.with("cache.delete")
.noargs();
}
}
// 消费者
@Component
@Slf4j
public class CacheDeleteConsumer {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@RabbitListener(queues = "cache.delete.queue")
public void handleCacheDelete(Map<String, Object> message) {
String cacheKey = (String) message.get("cacheKey");
Long timestamp = (Long) message.get("timestamp");
redisTemplate.delete(cacheKey);
log.info("延时删除缓存: key={}, delay={}ms",
cacheKey, System.currentTimeMillis() - timestamp);
}
}
六、缓存三大问题回顾
6.1 缓存穿透
问题: 查询不存在的数据,请求穿透到数据库
解决方案:
java
// 1. 空值缓存
if (product == null) {
redisTemplate.opsForValue().set(key, NullValue.INSTANCE, 5, TimeUnit.MINUTES);
}
// 2. 布隆过滤器
@Component
public class BloomFilterService {
private BloomFilter<Long> productBloomFilter;
@PostConstruct
public void init() {
// 预期元素数量100万,误判率0.01%
productBloomFilter = BloomFilter.create(
Funnels.longFunnel(),
1_000_000,
0.0001
);
// 初始化加载所有商品ID
List<Long> productIds = productMapper.selectAllIds();
productIds.forEach(productBloomFilter::put);
}
public boolean mightContain(Long productId) {
return productBloomFilter.mightContain(productId);
}
}
6.2 缓存击穿
问题: 热点key过期瞬间,大量请求击穿到数据库
解决方案: 分布式锁 + 互斥更新(见上文代码)
6.3 缓存雪崩
问题: 大量key同时过期,或Redis宕机
解决方案:
java
// 1. 过期时间随机化
Random random = new Random();
long expire = CACHE_EXPIRE_HOURS + random.nextInt(30); // 2小时 + 随机分钟
redisTemplate.opsForValue().set(key, value, expire, TimeUnit.MINUTES);
// 2. 多级缓存
@Service
public class MultiLevelCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private final Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
public Object get(String key) {
// L1: 本地缓存
Object value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// L2: Redis缓存
value = redisTemplate.opsForValue().get(key);
if (value != null) {
localCache.put(key, value);
return value;
}
// L3: 数据库
value = loadFromDB(key);
if (value != null) {
localCache.put(key, value);
redisTemplate.opsForValue().set(key, value);
}
return value;
}
}
// 3. 熔断降级
@HystrixCommand(
fallbackMethod = "getProductFallback",
commandProperties = {
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"),
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50")
}
)
public Product getProductWithFallback(Long productId) {
return getProduct(productId);
}
public Product getProductFallback(Long productId) {
// 降级:返回默认值或错误提示
return Product.defaultProduct();
}
七、总结与最佳实践
7.1 方案选择指南
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 低并发、强一致要求 | 分布式锁 + 双写 | 保证一致性 |
| 高并发、最终一致 | Cache Aside + 延时双删 | 性能好,最终一致 |
| 超高并发、解耦要求 | Canal监听binlog | 完全解耦,可靠性高 |
| 读多写少 | 缓存预热 + 延长过期 | 减少缓存失效 |
7.2 核心原则
- 先更新数据库,再删除缓存(推荐)
- 设置合理的缓存过期时间,避免雪崩
- 空值也要缓存,防止穿透
- 热点key加分布式锁,防止击穿
- 关键业务增加binlog监听,保证最终一致性
- 监控缓存命中率,及时发现问题
7.3 监控指标
java
@Component
public class CacheMetrics {
private final AtomicLong hitCount = new AtomicLong(0);
private final AtomicLong missCount = new AtomicLong(0);
public void recordHit() {
hitCount.incrementAndGet();
}
public void recordMiss() {
missCount.incrementAndGet();
}
@Scheduled(fixedRate = 60000)
public void reportMetrics() {
long hit = hitCount.getAndSet(0);
long miss = missCount.getAndSet(0);
long total = hit + miss;
if (total > 0) {
double hitRate = (double) hit / total * 100;
log.info("缓存命中率: {:.2f}% ({}/{})", hitRate, hit, total);
}
}
}
缓存一致性是分布式系统的经典难题,没有完美的方案,只有最适合的方案。在实际应用中,需要根据业务特点、并发量、一致性要求综合考虑,选择合适的策略组合使用。 **:《Redis高级应用详解》- 学习 Redis 的更多高级特性
- 扩展阅读:《数据密集型应用系统设计》一致性章节
📝 下一章预告
下一章将探索 Redis 的高级应用,包括发布订阅、Lua 脚本、Redlock 分布式锁、RediSearch 全文搜索等进阶主题,帮助你更深入地使用 Redis。
本章完