一、问题是什么?(先理解场景)
1. 经典问题:缓存与数据库不一致
场景:用户修改用户名
期望:缓存和数据库都更新为新名字
实际可能发生:
1. 先更新缓存 → 再更新数据库(数据库更新失败)
2. 先更新数据库 → 再更新缓存(缓存更新失败)
3. 并发读写导致数据错乱
2. 不一致的几种情况
// 情况1:先更新缓存,后更新数据库(不推荐)
public void updateUser(User user) {
// 1. 更新缓存
redis.set("user:" + user.id, user);
// 2. 更新数据库(可能失败!)
try {
userDao.update(user);
} catch (Exception e) {
// 数据库失败,但缓存已经是新数据
// 缓存是脏数据!
}
}
// 情况2:先更新数据库,后更新缓存
public void updateUser(User user) {
// 1. 更新数据库
userDao.update(user);
// 2. 更新缓存(可能失败!)
try {
redis.set("user:" + user.id, user);
} catch (Exception e) {
// 缓存失败,缓存还是旧数据
// 下次读会读到旧数据
}
}
二、解决方案总览
一致性解决方案:
├── 1. 先更新数据库,再删除缓存(Cache Aside Pattern)← 最常用
├── 2. 先删除缓存,再更新数据库
├── 3. 延迟双删
├── 4. 订阅数据库Binlog
└── 5. 最终一致性策略
三、方案1:Cache Aside Pattern(旁路缓存)- 推荐
1. 核心思想:数据库为主,缓存为辅
读流程:
1. 先读缓存
2. 缓存有 → 直接返回
3. 缓存无 → 读数据库 → 写入缓存 → 返回
写流程:
1. 更新数据库
2. 删除缓存
2. 完整代码实现
@Service
public class UserService {
@Autowired
private UserDao userDao;
@Autowired
private RedisTemplate<String, User> redisTemplate;
private static final String USER_KEY_PREFIX = "user:";
/**
* 读操作:先读缓存,缓存没有再读数据库
*/
public User getUser(Long userId) {
String key = USER_KEY_PREFIX + userId;
// 1. 从缓存读取
User user = redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 2. 缓存没有,从数据库读取
user = userDao.findById(userId);
if (user == null) {
return null; // 数据库也没有
}
// 3. 写入缓存(设置过期时间)
redisTemplate.opsForValue().set(
key,
user,
5, // 5分钟过期
TimeUnit.MINUTES
);
return user;
}
/**
* 写操作:先更新数据库,再删除缓存
*/
@Transactional
public void updateUser(User user) {
// 1. 更新数据库
userDao.update(user);
// 2. 删除缓存
String key = USER_KEY_PREFIX + user.getId();
redisTemplate.delete(key);
// 可选:延迟再次删除(防止极端情况)
new Thread(() -> {
try {
Thread.sleep(1000); // 延迟1秒
redisTemplate.delete(key);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
/**
* 删除操作:先删数据库,再删缓存
*/
@Transactional
public void deleteUser(Long userId) {
// 1. 删除数据库
userDao.delete(userId);
// 2. 删除缓存
String key = USER_KEY_PREFIX + userId;
redisTemplate.delete(key);
}
}
3. 为什么这个方案好?
优点:
1. 简单易懂,实现成本低
2. 大多数场景下一致性足够好
3. 缓存不总是有数据,减少缓存污染
缺点:
1. 首次请求会慢(缓存未命中)
2. 并发时可能短暂不一致
四、方案2:先删缓存,再更新数据库
1. 流程
写操作:
1. 删除缓存
2. 更新数据库
读操作:
1. 读缓存(无)→ 读数据库 → 写缓存
2. 代码实现
@Service
public class UserServiceV2 {
@Transactional
public void updateUser(User user) {
String key = USER_KEY_PREFIX + user.getId();
// 1. 先删除缓存
redisTemplate.delete(key);
// 2. 再更新数据库
userDao.update(user);
// 注意:这里可能有"缓存击穿"问题
// 在删除缓存后,更新数据库前,有其他线程读
}
public User getUser(Long userId) {
String key = USER_KEY_PREFIX + userId;
// 1. 读缓存
User user = redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 2. 缓存没有,读数据库
user = userDao.findById(userId);
if (user == null) {
return null;
}
// 3. 写回缓存
redisTemplate.opsForValue().set(key, user, 5, TimeUnit.MINUTES);
return user;
}
}
3. 问题:缓存击穿 + 短暂不一致
时间线:
线程A:删除缓存
线程B:读缓存(无)→ 读数据库(旧数据)→ 写缓存(旧数据)
线程A:更新数据库(新数据)
结果:缓存是旧数据,数据库是新数据
五、方案3:延迟双删(解决方案2的问题)
1. 流程
写操作:
1. 删除缓存
2. 更新数据库
3. 延迟一段时间(比如500ms)
4. 再次删除缓存
2. 代码实现
@Service
public class UserServiceV3 {
@Transactional
public void updateUser(User user) {
String key = USER_KEY_PREFIX + user.getId();
// 1. 第一次删除缓存
redisTemplate.delete(key);
// 2. 更新数据库
userDao.update(user);
// 3. 延迟后第二次删除缓存
// 用线程池或消息队列实现延迟
delayDeleteCache(key, 500); // 延迟500ms
}
/**
* 延迟删除缓存
*/
private void delayDeleteCache(String key, long delayMillis) {
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(delayMillis);
redisTemplate.delete(key);
log.info("延迟删除缓存:{}", key);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
/**
* 更优雅的方式:使用消息队列
*/
@Transactional
public void updateUserWithMQ(User user) {
String key = USER_KEY_PREFIX + user.getId();
// 1. 删除缓存
redisTemplate.delete(key);
// 2. 更新数据库
userDao.update(user);
// 3. 发送延迟消息
rabbitTemplate.convertAndSend(
"cache.delete.delay.exchange",
"cache.delete",
key,
message -> {
message.getMessageProperties()
.setDelay(500); // 延迟500ms
return message;
}
);
}
/**
* 消费延迟消息
*/
@RabbitListener(queues = "cache.delete.delay.queue")
public void handleDelayDelete(String key) {
redisTemplate.delete(key);
log.info("收到延迟消息,删除缓存:{}", key);
}
}
六、方案4:订阅数据库Binlog(最可靠)
1. 架构图
MySQL → Binlog → Canal/Otter → 消息队列 → 缓存更新服务 → Redis
↓
主库更新 ↓
异步更新缓存
2. 使用Canal实现
# canal部署配置
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=root
canal.instance.dbPassword=123456
canal.instance.filter.regex=.*\\..* # 监控所有表
// Canal客户端消费Binlog
@Component
public class CanalClient {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void processBinlog() {
// 连接Canal
CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress("127.0.0.1", 11111),
"destination", "", ""
);
connector.connect();
connector.subscribe(".*\\..*");
while (running) {
Message message = connector.getWithoutAck(100);
List<CanalEntry.Entry> entries = message.getEntries();
for (CanalEntry.Entry entry : entries) {
if (entry.getEntryType() == CanalEntry.EntryType.ROWDATA) {
CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(
entry.getStoreValue()
);
// 处理数据变更
processRowChange(rowChange, entry.getHeader().getTableName());
}
}
connector.ack(message.getId());
}
}
private void processRowChange(CanalEntry.RowChange rowChange, String tableName) {
if (!"user".equals(tableName)) {
return;
}
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
if (rowChange.getEventType() == CanalEntry.EventType.UPDATE ||
rowChange.getEventType() == CanalEntry.EventType.DELETE) {
// 获取主键ID
String userId = getUserIdFromRow(rowData.getBeforeColumnsList());
String key = USER_KEY_PREFIX + userId;
// 删除缓存
redisTemplate.delete(key);
log.info("监听到数据变更,删除缓存:{}", key);
}
}
}
}
3. 优点和缺点
优点:
1. 完全解耦,缓存更新与业务代码无关
2. 保证最终一致性
3. 性能影响最小
缺点:
1. 架构复杂,维护成本高
2. 有一定延迟(毫秒到秒级)
3. 需要额外的中间件
七、方案5:读写串行化(强一致性)
1. 使用分布式锁保证强一致
@Service
public class StrongConsistencyService {
@Autowired
private RedissonClient redissonClient;
/**
* 强一致性读
*/
public User getUserWithLock(Long userId) {
String lockKey = "user:lock:" + userId;
String cacheKey = USER_KEY_PREFIX + userId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 加读锁
lock.lock();
// 读缓存
User user = (User) redisTemplate.opsForValue().get(cacheKey);
if (user != null) {
return user;
}
// 读数据库
user = userDao.findById(userId);
if (user == null) {
return null;
}
// 写缓存
redisTemplate.opsForValue().set(cacheKey, user, 5, TimeUnit.MINUTES);
return user;
} finally {
lock.unlock();
}
}
/**
* 强一致性写
*/
@Transactional
public void updateUserWithLock(User user) {
String lockKey = "user:lock:" + user.getId();
String cacheKey = USER_KEY_PREFIX + user.getId();
RLock lock = redissonClient.getLock(lockKey);
try {
// 加写锁
lock.lock();
// 更新数据库
userDao.update(user);
// 删除缓存(或更新缓存)
redisTemplate.delete(cacheKey);
} finally {
lock.unlock();
}
}
}
八、不同场景的选型建议
1. 根据业务需求选择
// 场景1:用户基本信息(允许短暂不一致)
// 方案:Cache Aside Pattern
@Service
public class UserBasicService {
// 先更新数据库,再删缓存
// 简单可靠,适合大多数场景
}
// 场景2:商品库存(需要强一致性)
// 方案:分布式锁 + Cache Aside
@Service
public class InventoryService {
// 读写都加锁,保证强一致
// 性能较低,但数据准确
}
// 场景3:订单状态(最终一致性即可)
// 方案:订阅Binlog
@Service
public class OrderService {
// 数据库更新,Binlog同步到缓存
// 架构复杂,但性能最好
}
// 场景4:秒杀库存(高并发)
// 方案:Redis原子操作 + 异步同步
@Service
public class SeckillService {
// Redis预减库存
// 异步同步到数据库
// 保证高性能
}
2. 实际项目中的组合方案
@Component
public class CacheConsistencyManager {
// 1. 常规操作:Cache Aside
@Cacheable(value = "user", key = "#userId", unless = "#result == null")
public User getUser(Long userId) {
return userDao.findById(userId);
}
@CacheEvict(value = "user", key = "#user.id")
@Transactional
public void updateUser(User user) {
userDao.update(user);
}
// 2. 重要数据:加分布式锁
@Cacheable(value = "balance", key = "#accountId")
public BigDecimal getBalanceWithLock(Long accountId) {
String lockKey = "balance:lock:" + accountId;
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock();
return accountDao.getBalance(accountId);
} finally {
lock.unlock();
}
}
// 3. 监听Binlog更新缓存
@EventListener
public void onDatabaseChange(DatabaseChangeEvent event) {
if (event.getTable().equals("user")) {
String key = "user:" + event.getPrimaryKey();
redisTemplate.delete(key);
// 可选:发延迟消息双删
delayDelete(key, 1000);
}
}
}
九、处理特殊问题
1. 缓存穿透(查不存在的数据)
// 解决方案:布隆过滤器或缓存空值
public User getUserSafe(Long userId) {
String key = USER_KEY_PREFIX + userId;
// 1. 先查缓存
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) {
// 如果是空对象
if (user.getId() == null) {
return null; // 缓存了空值
}
return user;
}
// 2. 布隆过滤器判断
if (!bloomFilter.mightContain(userId)) {
return null; // 肯定不存在
}
// 3. 查数据库
user = userDao.findById(userId);
if (user == null) {
// 缓存空值,防止缓存穿透
redisTemplate.opsForValue().set(
key,
new User(), // 空对象
2, // 短时间过期
TimeUnit.MINUTES
);
return null;
}
// 4. 写缓存
redisTemplate.opsForValue().set(key, user, 5, TimeUnit.MINUTES);
return user;
}
2. 缓存雪崩(大量缓存同时失效)
// 解决方案:随机过期时间
public void setCacheWithRandomExpire(String key, Object value) {
// 基础过期时间 + 随机时间
int baseExpire = 5 * 60; // 5分钟
int randomExpire = new Random().nextInt(60); // 0-59秒随机
int totalExpire = baseExpire + randomExpire;
redisTemplate.opsForValue().set(
key,
value,
totalExpire,
TimeUnit.SECONDS
);
}
3. 热点key重建(缓存击穿)
// 解决方案:互斥锁重建
public User getHotUser(Long userId) {
String key = USER_KEY_PREFIX + userId;
// 1. 查缓存
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 2. 获取分布式锁
String lockKey = "rebuild:lock:" + key;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁,最多等待100ms
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// 双重检查
user = (User) redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 查询数据库
user = userDao.findById(userId);
if (user != null) {
// 写入缓存
redisTemplate.opsForValue().set(
key, user, 5, TimeUnit.MINUTES
);
}
return user;
} finally {
lock.unlock();
}
} else {
// 没获取到锁,等待一下再查缓存
Thread.sleep(50);
return (User) redisTemplate.opsForValue().get(key);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
十、Spring Cache整合方案
1. 使用Spring Cache注解
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5)) // 默认5分钟
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
@Service
public class UserService {
@Cacheable(value = "user", key = "#userId")
public User getUser(Long userId) {
return userDao.findById(userId);
}
@CachePut(value = "user", key = "#user.id")
@Transactional
public User updateUser(User user) {
userDao.update(user);
return user;
}
@CacheEvict(value = "user", key = "#userId")
@Transactional
public void deleteUser(Long userId) {
userDao.delete(userId);
}
}
2. 自定义Cache Aside模式
@Component
public class CacheAsideTemplate {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 通用的Cache Aside读模板
*/
public <T> T readThrough(String key, Class<T> type,
Supplier<T> loader, long expire, TimeUnit unit) {
// 1. 读缓存
T value = (T) redisTemplate.opsForValue().get(key);
if (value != null) {
return value;
}
// 2. 读数据库
value = loader.get();
if (value == null) {
// 缓存空值,防止缓存穿透
redisTemplate.opsForValue().set(key, new NullValue(), 2, TimeUnit.MINUTES);
return null;
}
// 3. 写缓存
redisTemplate.opsForValue().set(key, value, expire, unit);
return value;
}
/**
* 通用的Cache Aside写模板
*/
public void writeThrough(String key, Runnable writer) {
// 1. 更新数据库
writer.run();
// 2. 删除缓存
redisTemplate.delete(key);
}
}
// 使用模板
@Service
public class UserService {
@Autowired
private CacheAsideTemplate cacheTemplate;
public User getUser(Long userId) {
String key = "user:" + userId;
return cacheTemplate.readThrough(
key,
User.class,
() -> userDao.findById(userId),
5,
TimeUnit.MINUTES
);
}
public void updateUser(User user) {
String key = "user:" + user.getId();
cacheTemplate.writeThrough(key, () -> {
userDao.update(user);
});
}
}
十一、监控和告警
1. 监控缓存命中率
@Component
public class CacheMonitor {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private AtomicLong hitCount = new AtomicLong(0);
private AtomicLong missCount = new AtomicLong(0);
public <T> T getWithMonitor(String key, Supplier<T> loader) {
T value = (T) redisTemplate.opsForValue().get(key);
if (value != null) {
hitCount.incrementAndGet();
return value;
} else {
missCount.incrementAndGet();
value = loader.get();
if (value != null) {
redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
}
return value;
}
}
public double getHitRate() {
long total = hitCount.get() + missCount.get();
if (total == 0) return 0.0;
return (double) hitCount.get() / total;
}
@Scheduled(fixedRate = 60000) // 每分钟上报
public void reportMetrics() {
double hitRate = getHitRate();
// 上报到监控系统
metricsService.gauge("cache.hit.rate", hitRate);
// 命中率过低告警
if (hitRate < 0.8) {
alertService.sendAlert("缓存命中率过低:" + hitRate);
}
}
}
十二、最佳实践总结
1. 选择策略的黄金法则
读多写少 → Cache Aside Pattern
写多读少 → Write Through/Write Behind
强一致性 → 分布式锁 + Cache Aside
最终一致 → Binlog同步
2. 代码规范
// ✅ 推荐写法
@Service
public class GoodPracticeService {
// 1. 使用@Transactional保证数据库原子性
@Transactional
@CacheEvict(value = "user", key = "#user.id")
public void updateUser(User user) {
userDao.update(user);
}
// 2. 设置合理的过期时间
@Cacheable(value = "user", key = "#userId",
unless = "#result == null",
cacheResolver = "dynamicTtlCacheResolver")
public User getUser(Long userId) {
return userDao.findById(userId);
}
// 3. 重要操作记录日志
@CacheEvict(value = "user", key = "#userId")
@Transactional
public void deleteUser(Long userId) {
log.info("删除用户,userId={}", userId);
userDao.delete(userId);
}
// 4. 考虑失败重试
@Retryable(value = RedisConnectionFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 1000))
public void updateCache(String key, Object value) {
redisTemplate.opsForValue().set(key, value);
}
}
3. 一句话记住
Cache Aside Pattern是万能钥匙,Binlog同步是终极方案,分布式锁是急救包。
日常开发就用Cache Aside(先更新数据库,再删缓存),加上适当的监控和重试机制,能解决95%的场景。