Redis与MySQL双写一致性(实战解决方案)

一、问题是什么?(先理解场景)

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%的场景。

相关推荐
我是小妖怪,潇洒又自在3 小时前
springcloud alibaba(九)Nacos Config服务配置
后端·spring·spring cloud
qq_12498707534 小时前
重庆三峡学院图书资料管理系统设计与实现(源码+论文+部署+安装)
java·spring boot·后端·mysql·spring·毕业设计
小鸡脚来咯4 小时前
Redis三大问题:穿透、击穿、雪崩(实战解析)
java·spring·mybatis
jiayong234 小时前
Spring AI Alibaba 深度解析(三):实战示例与最佳实践
java·人工智能·spring
l1t6 小时前
用docker安装oracle 19c
运维·数据库·docker·oracle·容器
Boilermaker19926 小时前
[MySQL] 设计范式与 E-R 图绘制
mysql·oracle·设计规范
⑩-7 小时前
SpringCloud-Feign&RestTemplate
后端·spring·spring cloud
由之7 小时前
Spring事件监听机制简单使用
java·spring
Li_7695327 小时前
Redis —— (五)
java·redis·后端·spring