MySQL数据库与缓存一致性保障方案

MySQL数据库与缓存一致性保障方案

作者:技术团队
更新时间:2026-01-16
文档类型:技术知识库


目录


一、问题背景与本质

1.1 为什么会出现不一致

在分布式系统中,数据库和缓存是两个独立的系统,会出现以下问题:

问题类型 说明 影响
原子性问题 数据库和缓存更新不是原子操作 可能只成功一个
并发问题 多个请求同时读写 脏数据覆盖新数据
网络延迟 缓存删除/更新有延迟 短暂数据不一致
操作失败 数据库成功但缓存失败(或相反) 长期数据不一致

1.2 一致性分类

graph LR A[数据一致性] --> B[强一致性] A --> C[最终一致性] A --> D[弱一致性] B --> B1[实时同步<br/>性能差] C --> C1[异步同步<br/>可接受延迟] D --> D1[不保证一致<br/>性能最好]

实际应用:

  • 强一致性场景: 金融交易、库存扣减、账户余额
  • 最终一致性场景: 用户信息、商品详情、文章内容(大部分业务场景)
  • 弱一致性场景: 浏览次数、点赞数、在线人数

1.3 核心原则

⚠️ 重要原则:数据库是唯一的数据源(Single Source of Truth),缓存只是辅助!

优先级:数据库正确性 > 缓存一致性 > 性能

二、缓存更新策略详解

2.1 Cache Aside 模式(旁路缓存)★★★★★

最常用的模式,适合大部分业务场景。

读操作流程

sequenceDiagram participant App as 应用 participant Cache as 缓存(Redis) participant DB as 数据库(MySQL) App->>Cache: 1. 查询缓存 alt 缓存命中 Cache-->>App: 返回数据 else 缓存未命中 Cache-->>App: 返回null App->>DB: 2. 查询数据库 DB-->>App: 返回数据 App->>Cache: 3. 写入缓存 Cache-->>App: 完成 end

Java实现:

@Service
public class UserService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private UserMapper userMapper;
    
    /**
     * 读取用户信息 - Cache Aside 模式
     */
    public UserInfo getUserById(Long userId) {
        String key = "user:info:" + userId;
        
        // 1. 查缓存
        UserInfo userInfo = (UserInfo) redisTemplate.opsForValue().get(key);
        if (userInfo != null) {
            log.info("缓存命中: {}", key);
            return userInfo;
        }
        
        log.info("缓存未命中,查询数据库: {}", userId);
        
        // 2. 查数据库
        userInfo = userMapper.selectById(userId);
        if (userInfo == null) {
            return null;
        }
        
        // 3. 写入缓存(设置30分钟过期)
        redisTemplate.opsForValue().set(key, userInfo, 30, TimeUnit.MINUTES);
        
        return userInfo;
    }
}

写操作流程(推荐方案)

sequenceDiagram participant App as 应用 participant DB as 数据库(MySQL) participant Cache as 缓存(Redis) App->>DB: 1. 更新数据库 DB-->>App: 更新成功 App->>Cache: 2. 删除缓存 Cache-->>App: 删除成功 Note over App,Cache: 下次读取时会重新加载最新数据

Java实现:

/**
 * 更新用户信息 - 先更新数据库,再删除缓存
 */
@Transactional(rollbackFor = Exception.class)
public void updateUser(UserInfo userInfo) {
    // 1. 更新数据库
    int rows = userMapper.updateById(userInfo);
    if (rows == 0) {
        throw new BusinessException("用户不存在");
    }
    
    // 2. 删除缓存
    String key = "user:info:" + userInfo.getId();
    redisTemplate.delete(key);
    
    log.info("更新用户成功,已删除缓存: {}", key);
}

2.2 为什么删除缓存而不是更新缓存?★★★★★

这是面试高频问题!

对比项 删除缓存 更新缓存
实现复杂度 ✅ 简单,只需delete ❌ 复杂,需要重新查询和计算
性能开销 ✅ 低,只是删除操作 ❌ 高,每次都要查库和计算
无效更新 ✅ 不存在 ❌ 可能多次更新但没人读
数据一致性 ✅ 下次读取时加载最新数据 ❌ 更新过程中可能出错

场景对比:

// ❌ 更新缓存的问题
public void updateUser(UserInfo userInfo) {
    // 1. 更新数据库
    userMapper.updateById(userInfo);
    
    // 2. 更新缓存 - 需要重新查询和计算
    // 如果用户信息涉及关联查询,成本很高
    UserInfoDTO dto = buildComplexUserDTO(userInfo.getId()); // 查询爵位、勋章、等级等
    redisTemplate.opsForValue().set(key, dto);
    
    // 问题:如果短时间内更新3次,就要查库3次,但可能没人读取!
}

// ✅ 删除缓存的优势
public void updateUser(UserInfo userInfo) {
    // 1. 更新数据库
    userMapper.updateById(userInfo);
    
    // 2. 删除缓存
    redisTemplate.delete(key);
    
    // 优势:只有被读取时才会重新加载,避免无效计算
}

2.3 其他缓存模式(了解)

Read Through(读穿透)

缓存框架自动处理缓存加载,业务代码只需要读取缓存。

// 类似Spring Cache的 @Cacheable
@Cacheable(value = "users", key = "#userId")
public UserInfo getUserById(Long userId) {
    return userMapper.selectById(userId);
}

Write Through(写穿透)

更新数据时,缓存框架自动同步更新缓存和数据库。

@CachePut(value = "users", key = "#userInfo.id")
public UserInfo updateUser(UserInfo userInfo) {
    userMapper.updateById(userInfo);
    return userInfo;
}

Write Behind(写回)

先更新缓存,异步批量更新数据库(适合写多读少的场景)。

不推荐: 可能丢失数据,一致性难以保证。


三、常见的不一致场景

3.1 场景1:先删除缓存,再更新数据库 ❌

问题: 并发读写导致旧数据重新写入缓存

sequenceDiagram participant A as 请求A(更新) participant B as 请求B(查询) participant Cache as 缓存 participant DB as 数据库 A->>Cache: 1. 删除缓存 Cache-->>A: 删除成功 B->>Cache: 2. 查询缓存(未命中) B->>DB: 3. 查询数据库(旧数据) DB-->>B: 返回旧数据 A->>DB: 4. 更新数据库 DB-->>A: 更新成功 B->>Cache: 5. 将旧数据写入缓存 Note over Cache: ❌ 缓存是旧数据,数据库是新数据

时间线:

T1: 请求A删除缓存
T2: 请求B查询,缓存未命中,读取旧数据
T3: 请求B将旧数据写入缓存
T4: 请求A更新数据库
→ 结果:缓存旧数据,数据库新数据,不一致!

解决方案: 改为先更新数据库,再删除缓存(见场景2)


3.2 场景2:先更新数据库,再删除缓存 - 删除失败 ❌

问题: 删除缓存时发生异常(网络超时、Redis宕机等)

sequenceDiagram participant App as 应用 participant DB as 数据库 participant Cache as 缓存 App->>DB: 1. 更新数据库 DB-->>App: 更新成功 App->>Cache: 2. 删除缓存 Cache-->>App: ❌ 删除失败(网络超时) Note over Cache,DB: 数据库新数据,缓存旧数据,不一致!

Java代码:

// 问题代码
public void updateUser(UserInfo userInfo) {
    // 1. 更新数据库(成功)
    userMapper.updateById(userInfo);
    
    // 2. 删除缓存(失败)
    try {
        redisTemplate.delete(key);
    } catch (Exception e) {
        // Redis异常,缓存删除失败
        log.error("删除缓存失败", e);
        // ❌ 数据已经不一致了!
    }
}

解决方案: 见第四章的重试机制、延迟双删、MQ保障等方案


3.3 场景3:先更新数据库,再删除缓存 - 并发问题 ⚠️

问题: 极端情况下的并发读写(发生概率较低)

sequenceDiagram participant A as 请求A(更新) participant B as 请求B(查询) participant Cache as 缓存 participant DB as 数据库 B->>Cache: 1. 查询缓存(未命中) B->>DB: 2. 查询数据库(旧数据) A->>DB: 3. 更新数据库 DB-->>A: 更新成功 A->>Cache: 4. 删除缓存 Cache-->>A: 删除成功 DB-->>B: 5. 返回旧数据 B->>Cache: 6. 将旧数据写入缓存 Note over Cache: ⚠️ 缓存又是旧数据了

为什么发生概率低?

这个场景需要满足:

  1. 缓存刚好失效
  2. 请求B读取数据库速度慢
  3. 请求A更新+删除缓存的速度快
  4. 请求B读数据库比请求A的更新+删除还慢

时间窗口分析:

正常情况:数据库查询(10ms) << 数据库更新(50ms) + 缓存删除(5ms)
异常情况:数据库查询被阻塞(慢查询、锁等待) > 100ms

解决方案: 延迟双删(见第四章)


四、一致性保障方案

4.1 方案1:延迟双删 ★★★★☆

原理: 删除两次缓存,兜底清除可能被并发请求写入的旧数据

sequenceDiagram participant App as 应用 participant DB as 数据库 participant Cache as 缓存 App->>Cache: 1. 第一次删除缓存 App->>DB: 2. 更新数据库 Note over App: 延迟500ms-1s App->>Cache: 3. 第二次删除缓存(兜底) Note over Cache: 即使并发请求写入了旧数据<br/>也会被第二次删除清理掉

Java实现:

@Service
public class UserService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private UserMapper userMapper;
    
    @Autowired
    private ThreadPoolTaskExecutor asyncExecutor;
    
    /**
     * 延迟双删方案
     */
    @Transactional(rollbackFor = Exception.class)
    public void updateUser(UserInfo userInfo) {
        String key = "user:info:" + userInfo.getId();
        
        // 1. 第一次删除缓存
        redisTemplate.delete(key);
        log.info("第一次删除缓存: {}", key);
        
        // 2. 更新数据库
        userMapper.updateById(userInfo);
        log.info("更新数据库成功: {}", userInfo.getId());
        
        // 3. 异步延迟第二次删除缓存
        asyncExecutor.execute(() -> {
            try {
                // 延迟时间根据业务场景调整(一般500ms-1s)
                Thread.sleep(500);
                redisTemplate.delete(key);
                log.info("第二次删除缓存(延迟): {}", key);
            } catch (InterruptedException e) {
                log.error("延迟双删异常", e);
                Thread.currentThread().interrupt();
            }
        });
    }
}

延迟时间如何确定?

延迟时间 > 读取数据库并写入缓存的时间

一般设置:
- 低并发场景:500ms
- 高并发场景:1000ms
- 极端场景:根据监控的P99耗时设置

优点:

  • ✅ 简单易实现
  • ✅ 能解决大部分并发问题
  • ✅ 适合中小型项目

缺点:

  • ❌ 延迟时间不好确定
  • ❌ 第二次删除仍可能失败
  • ❌ 增加了响应时间(异步可缓解)

4.2 方案2:消息队列异步重试 ★★★★★

原理: 通过MQ的重试机制保证缓存最终被删除

sequenceDiagram participant App as 应用 participant DB as 数据库 participant MQ as 消息队列 participant Consumer as 消费者 participant Cache as 缓存 App->>DB: 1. 更新数据库(事务) App->>MQ: 2. 发送删除缓存消息 MQ->>Consumer: 3. 投递消息 Consumer->>Cache: 4. 删除缓存 alt 删除成功 Cache-->>Consumer: 成功 Consumer-->>MQ: ACK else 删除失败 Cache-->>Consumer: 失败 Consumer-->>MQ: NACK MQ->>Consumer: 5. 重试(指数退避) end

Java实现(RocketMQ):

// ========== 生产者 ==========
@Service
public class UserService {
    
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    
    @Autowired
    private UserMapper userMapper;
    
    /**
     * 更新用户 - MQ异步删除缓存
     */
    @Transactional(rollbackFor = Exception.class)
    public void updateUser(UserInfo userInfo) {
        // 1. 更新数据库
        userMapper.updateById(userInfo);
        log.info("更新数据库成功: {}", userInfo.getId());
        
        // 2. 发送MQ消息删除缓存
        CacheDeleteMessage message = CacheDeleteMessage.builder()
                .cacheKey("user:info:" + userInfo.getId())
                .userId(userInfo.getId())
                .operationType("UPDATE")
                .timestamp(System.currentTimeMillis())
                .build();
        
        // 同步发送,确保消息发送成功
        SendResult sendResult = rocketMQTemplate.syncSend(
                "cache-delete-topic", 
                message,
                3000 // 3秒超时
        );
        
        if (sendResult.getSendStatus() != SendStatus.SEND_OK) {
            throw new BusinessException("发送MQ消息失败");
        }
        
        log.info("发送删除缓存消息成功: {}", message);
    }
}

// ========== 消息实体 ==========
@Data
@Builder
public class CacheDeleteMessage {
    private String cacheKey;
    private Long userId;
    private String operationType;
    private Long timestamp;
}

// ========== 消费者 ==========
@Component
@RocketMQMessageListener(
    topic = "cache-delete-topic",
    consumerGroup = "cache-delete-consumer-group",
    consumeMode = ConsumeMode.CONCURRENTLY,
    maxReconsumeTimes = 3 // 最多重试3次
)
public class CacheDeleteConsumer implements RocketMQListener<CacheDeleteMessage> {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Override
    public void onMessage(CacheDeleteMessage message) {
        log.info("收到删除缓存消息: {}", message);
        
        try {
            // 删除缓存
            Boolean deleted = redisTemplate.delete(message.getCacheKey());
            
            if (Boolean.TRUE.equals(deleted)) {
                log.info("删除缓存成功: {}", message.getCacheKey());
            } else {
                log.warn("缓存可能已不存在: {}", message.getCacheKey());
            }
            
        } catch (Exception e) {
            log.error("删除缓存失败,将重试: {}", message, e);
            // 抛出异常,触发RocketMQ重试机制
            throw new RuntimeException("删除缓存失败", e);
        }
    }
}

重试策略配置:

@Configuration
public class RocketMQConfig {
    
    @Bean
    public DefaultMQPushConsumer cacheDeleteConsumer() {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("cache-delete-consumer-group");
        
        // 重试间隔:1s, 5s, 10s, 30s, 1m, 2m, 3m, 4m, 5m, 6m, 7m, 8m, 9m, 10m, 20m, 30m, 1h, 2h
        consumer.setMaxReconsumeTimes(3); // 最多重试3次
        
        return consumer;
    }
}

优点:

  • ✅ 解耦业务逻辑和缓存删除
  • ✅ 支持自动重试机制
  • ✅ 高可用(MQ集群保证消息不丢失)
  • ✅ 适合大型分布式系统

缺点:

  • ❌ 增加系统复杂度(需要MQ)
  • ❌ 有一定延迟(消息投递时间)

4.3 方案3:订阅MySQL Binlog(Canal)★★★★★

原理: 监听MySQL的Binlog日志,数据库变更时自动删除缓存

graph LR A[应用] -->|1. 只更新数据库| B[MySQL] B -->|2. 写入Binlog| C[Binlog日志] C -->|3. Canal订阅| D[Canal Server] D -->|4. 解析变更| E[Canal Client] E -->|5. 删除缓存| F[Redis] style A fill:#e1f5ff style B fill:#ffe1e1 style D fill:#fff4e1 style F fill:#e1ffe1

架构优势:

  • 业务代码无侵入
  • 数据库是唯一数据源
  • 支持数据同步、实时计算等扩展场景

Java实现(Canal):

// ========== 1. 引入依赖 ==========
// pom.xml
<dependency>
    <groupId>com.alibaba.otter</groupId>
    <artifactId>canal.client</artifactId>
    <version>1.1.7</version>
</dependency>

// ========== 2. Canal监听器 ==========
@Component
@Slf4j
public class CanalCacheHandler {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 监听用户表变更
     */
    @CanalEventListener(
        destination = "example",
        schema = "party_user",
        table = "user_info",
        eventType = {CanalEntry.EventType.UPDATE, CanalEntry.EventType.DELETE}
    )
    public void handleUserChange(CanalEntry.EventType eventType, CanalEntry.RowData rowData) {
        log.info("监听到用户表变更: eventType={}", eventType);
        
        try {
            // 解析用户ID
            Long userId = null;
            for (CanalEntry.Column column : rowData.getAfterColumnsList()) {
                if ("id".equals(column.getName())) {
                    userId = Long.parseLong(column.getValue());
                    break;
                }
            }
            
            if (userId == null) {
                log.warn("未找到用户ID,跳过缓存删除");
                return;
            }
            
            // 删除用户相关缓存
            String[] cacheKeys = {
                "user:info:" + userId,
                "user:extend:" + userId,
                "user:level:" + userId
            };
            
            Long deleted = redisTemplate.delete(Arrays.asList(cacheKeys));
            log.info("删除用户缓存成功: userId={}, keys={}, deleted={}", userId, cacheKeys, deleted);
            
        } catch (Exception e) {
            log.error("处理Canal变更事件失败", e);
            // 不抛出异常,避免影响Canal消费
        }
    }
    
    /**
     * 监听礼物表变更
     */
    @CanalEventListener(
        destination = "example",
        schema = "party_assets",
        table = "gift_info",
        eventType = CanalEntry.EventType.UPDATE
    )
    public void handleGiftChange(CanalEntry.EventType eventType, CanalEntry.RowData rowData) {
        log.info("监听到礼物表变更");
        
        // 删除礼物列表缓存
        redisTemplate.delete("gift:list:all");
        log.info("删除礼物列表缓存成功");
    }
}

// ========== 3. Canal配置 ==========
@Configuration
public class CanalConfig {
    
    @Bean
    public CanalConnector canalConnector() {
        // Canal Server地址
        return CanalConnectors.newSingleConnector(
                new InetSocketAddress("127.0.0.1", 11111),
                "example", // destination
                "", // username
                "" // password
        );
    }
}

MySQL配置(开启Binlog):

-- 1. 查看Binlog是否开启
SHOW VARIABLES LIKE 'log_bin';

-- 2. my.cnf配置
[mysqld]
log-bin=mysql-bin
binlog-format=ROW
server-id=1

-- 3. 创建Canal用户
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;

Canal Server配置:

# canal.properties
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal
canal.instance.filter.regex=party_user\\.user_info,party_assets\\.gift_info

优点:

  • ✅ 业务代码无侵入(最大优势)
  • ✅ 数据库变更100%被捕获
  • ✅ 支持多种下游消费(缓存删除、数据同步、实时计算)
  • ✅ 适合大厂复杂场景

缺点:

  • ❌ 架构复杂度高(需要Canal服务)
  • ❌ 运维成本高(需要监控Canal)
  • ❌ 有一定延迟(Binlog解析和投递)

4.4 方案4:设置缓存过期时间(兜底方案)★★★★★

原理: 即使缓存不一致,过期后会自动加载最新数据

/**
 * 所有缓存都应该设置过期时间
 */
public UserInfo getUserById(Long userId) {
    String key = "user:info:" + userId;
    
    UserInfo userInfo = (UserInfo) redisTemplate.opsForValue().get(key);
    if (userInfo != null) {
        return userInfo;
    }
    
    userInfo = userMapper.selectById(userId);
    if (userInfo != null) {
        // ✅ 设置过期时间(兜底方案)
        // 即使删除缓存失败,30分钟后也会自动过期
        redisTemplate.opsForValue().set(key, userInfo, 30, TimeUnit.MINUTES);
    }
    
    return userInfo;
}

过期时间设置建议:

数据类型 过期时间 说明
用户基础信息 30分钟 更新频率低
用户等级/爵位 10分钟 可能变化
礼物列表 1小时 基本不变
活动配置 5分钟 运营可能调整
热点数据 永不过期 主动删除 + 监控

随机过期时间(防止缓存雪崩):

/**
 * 设置随机过期时间,避免大量缓存同时失效
 */
public void setCacheWithRandomExpire(String key, Object value, long baseExpireMinutes) {
    // 基础时间 + 随机时间(±10%)
    long randomMinutes = baseExpireMinutes + ThreadLocalRandom.current().nextLong(
            -baseExpireMinutes / 10,
            baseExpireMinutes / 10
    );
    
    redisTemplate.opsForValue().set(key, value, randomMinutes, TimeUnit.MINUTES);
    log.info("设置缓存: key={}, expire={}min", key, randomMinutes);
}

4.5 方案对比与选择

方案 实现复杂度 一致性保障 性能影响 适用场景
延迟双删 ⭐⭐ 简单 ⭐⭐⭐ 中等 ⭐⭐⭐⭐ 低 中小型项目,并发不高
MQ异步重试 ⭐⭐⭐ 中等 ⭐⭐⭐⭐ 高 ⭐⭐⭐ 中等 大型项目,高并发场景
Canal Binlog ⭐⭐⭐⭐ 复杂 ⭐⭐⭐⭐⭐ 最高 ⭐⭐⭐⭐ 低 大厂,微服务架构
缓存过期 ⭐ 最简单 ⭐⭐ 弱 ⭐⭐⭐⭐⭐ 无 所有场景(兜底)

组合方案推荐:

🏆 最佳实践 = MQ异步重试 + 缓存过期 + 监控告警

中小型项目 = 延迟双删 + 缓存过期
大型项目 = MQ异步重试 + 缓存过期 + 监控
超大型项目 = Canal Binlog + 缓存过期 + 监控

五、项目实战方案

5.1 完整的缓存服务封装

/**
 * 缓存服务 - 统一封装缓存操作
 */
@Service
@Slf4j
public class CacheService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    
    /**
     * 查询缓存 - 支持缓存穿透防护
     */
    public <T> T get(String key, Class<T> type, Supplier<T> dbLoader) {
        // 1. 查缓存
        T value = (T) redisTemplate.opsForValue().get(key);
        if (value != null) {
            log.debug("缓存命中: {}", key);
            return value;
        }
        
        // 2. 查数据库
        value = dbLoader.get();
        if (value == null) {
            // 缓存空值,防止缓存穿透
            redisTemplate.opsForValue().set(key, NULL_VALUE, 5, TimeUnit.MINUTES);
            return null;
        }
        
        // 3. 写入缓存
        setWithRandomExpire(key, value, 30);
        
        return value;
    }
    
    /**
     * 更新缓存 - 先删除缓存,发送MQ异步删除
     */
    public void delete(String key) {
        try {
            // 1. 同步删除缓存
            redisTemplate.delete(key);
            log.info("删除缓存成功: {}", key);
            
            // 2. 发送MQ消息(兜底)
            CacheDeleteMessage message = CacheDeleteMessage.builder()
                    .cacheKey(key)
                    .timestamp(System.currentTimeMillis())
                    .build();
            
            rocketMQTemplate.asyncSend("cache-delete-topic", message, new SendCallback() {
                @Override
                public void onSuccess(SendResult sendResult) {
                    log.info("发送删除缓存消息成功: key={}", key);
                }
                
                @Override
                public void onException(Throwable e) {
                    log.error("发送删除缓存消息失败: key={}", key, e);
                    // 告警
                    alertService.send("缓存删除MQ发送失败: " + key);
                }
            });
            
        } catch (Exception e) {
            log.error("删除缓存异常: key={}", key, e);
            // 告警
            alertService.send("缓存删除失败: " + key);
        }
    }
    
    /**
     * 批量删除缓存
     */
    public void deletePattern(String pattern) {
        Set<String> keys = redisTemplate.keys(pattern);
        if (keys != null && !keys.isEmpty()) {
            redisTemplate.delete(keys);
            log.info("批量删除缓存: pattern={}, count={}", pattern, keys.size());
        }
    }
    
    /**
     * 设置缓存(随机过期时间)
     */
    private void setWithRandomExpire(String key, Object value, long baseExpireMinutes) {
        long randomMinutes = baseExpireMinutes + ThreadLocalRandom.current().nextLong(
                -baseExpireMinutes / 10,
                baseExpireMinutes / 10
        );
        redisTemplate.opsForValue().set(key, value, randomMinutes, TimeUnit.MINUTES);
    }
    
    private static final String NULL_VALUE = "NULL";
}

5.2 用户服务完整示例

@Service
@Slf4j
public class UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    @Autowired
    private UserInfoApi userInfoApi;
    
    @Autowired
    private CacheService cacheService;
    
    /**
     * 查询用户信息
     */
    public UserInfo getUserById(Long userId) {
        String key = "user:info:" + userId;
        
        return cacheService.get(key, UserInfo.class, () -> {
            // 查询数据库
            return userInfoApi.getUserInfoById(userId);
        });
    }
    
    /**
     * 查询用户扩展信息(包含爵位等)
     */
    public UserInfoExtend getUserExtendById(Long userId) {
        String key = "user:extend:" + userId;
        
        return cacheService.get(key, UserInfoExtend.class, () -> {
            // 查询数据库(包含爵位等额外字段)
            return userInfoApi.insideGetExtendById(userId);
        });
    }
    
    /**
     * 批量查询用户(避免循环查询)
     */
    public Map<Long, UserInfo> getUserMapByIds(List<Long> userIds) {
        if (CollectionUtils.isEmpty(userIds)) {
            return Collections.emptyMap();
        }
        
        // 1. 先批量查询数据库
        List<UserInfo> userList = userInfoApi.getUserInfoListByIds(userIds);
        
        // 2. 转Map
        return userList.stream()
                .collect(Collectors.toMap(UserInfo::getId, Function.identity()));
    }
    
    /**
     * 更新用户信息 - 删除缓存
     */
    @Transactional(rollbackFor = Exception.class)
    public void updateUser(UserInfo userInfo) {
        // 1. 更新数据库
        int rows = userMapper.updateById(userInfo);
        if (rows == 0) {
            throw new BusinessException("用户不存在");
        }
        
        // 2. 删除相关缓存
        cacheService.delete("user:info:" + userInfo.getId());
        cacheService.delete("user:extend:" + userInfo.getId());
        
        log.info("更新用户成功: userId={}", userInfo.getId());
    }
    
    /**
     * 更新用户等级 - 删除多个缓存
     */
    @Transactional(rollbackFor = Exception.class)
    public void updateUserLevel(Long userId, Integer level) {
        // 1. 更新数据库
        userMapper.updateLevel(userId, level);
        
        // 2. 删除相关缓存
        String[] keys = {
            "user:info:" + userId,
            "user:extend:" + userId,
            "user:level:" + userId
        };
        
        for (String key : keys) {
            cacheService.delete(key);
        }
        
        log.info("更新用户等级成功: userId={}, level={}", userId, level);
    }
}

六、缓存常见问题

6.1 缓存穿透

问题: 查询不存在的数据,缓存和数据库都没有,每次都打到数据库

sequenceDiagram participant Client as 客户端 participant App as 应用 participant Cache as 缓存 participant DB as 数据库 Client->>App: 查询userId=99999(不存在) App->>Cache: 查缓存 Cache-->>App: 未命中 App->>DB: 查数据库 DB-->>App: 不存在 Note over Client,DB: 攻击者不断请求不存在的ID<br/>每次都打到数据库

解决方案1:布隆过滤器

@Service
public class UserService {
    
    @Autowired
    private RedissonClient redisson;
    
    private RBloomFilter<Long> userIdBloomFilter;
    
    @PostConstruct
    public void init() {
        // 初始化布隆过滤器
        userIdBloomFilter = redisson.getBloomFilter("user:id:bloom");
        userIdBloomFilter.tryInit(10000000L, 0.01); // 1000万用户,1%误判率
        
        // 加载已有用户ID
        List<Long> userIds = userMapper.selectAllUserIds();
        for (Long userId : userIds) {
            userIdBloomFilter.add(userId);
        }
    }
    
    public UserInfo getUserById(Long userId) {
        // 1. 布隆过滤器判断
        if (!userIdBloomFilter.contains(userId)) {
            log.warn("用户ID不存在(布隆过滤器): {}", userId);
            return null;
        }
        
        // 2. 查缓存和数据库
        String key = "user:info:" + userId;
        return cacheService.get(key, UserInfo.class, () -> {
            return userMapper.selectById(userId);
        });
    }
    
    /**
     * 新增用户时,添加到布隆过滤器
     */
    @Transactional(rollbackFor = Exception.class)
    public void createUser(UserInfo userInfo) {
        userMapper.insert(userInfo);
        
        // 添加到布隆过滤器
        userIdBloomFilter.add(userInfo.getId());
    }
}

解决方案2:缓存空值

public UserInfo getUserById(Long userId) {
    String key = "user:info:" + userId;
    
    // 1. 查缓存
    Object cached = redisTemplate.opsForValue().get(key);
    if (cached != null) {
        // 空值标记
        if ("NULL".equals(cached)) {
            return null;
        }
        return (UserInfo) cached;
    }
    
    // 2. 查数据库
    UserInfo userInfo = userMapper.selectById(userId);
    if (userInfo == null) {
        // 缓存空值,5分钟过期
        redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
        return null;
    }
    
    // 3. 缓存正常数据
    redisTemplate.opsForValue().set(key, userInfo, 30, TimeUnit.MINUTES);
    return userInfo;
}

6.2 缓存击穿

问题: 热点数据过期,大量请求同时打到数据库

sequenceDiagram participant C1 as 请求1 participant C2 as 请求2 participant C3 as 请求3 participant Cache as 缓存 participant DB as 数据库 Note over Cache: 热点数据过期 C1->>Cache: 查询 C2->>Cache: 查询 C3->>Cache: 查询 Cache-->>C1: 未命中 Cache-->>C2: 未命中 Cache-->>C3: 未命中 C1->>DB: 查询 C2->>DB: 查询 C3->>DB: 查询 Note over DB: 数据库压力瞬间增大

解决方案1:分布式锁

@Service
public class UserService {
    
    @Autowired
    private RedissonClient redisson;
    
    public UserInfo getUserById(Long userId) {
        String key = "user:info:" + userId;
        
        // 1. 查缓存
        UserInfo userInfo = (UserInfo) redisTemplate.opsForValue().get(key);
        if (userInfo != null) {
            return userInfo;
        }
        
        // 2. 分布式锁(防止缓存击穿)
        String lockKey = "lock:user:" + userId;
        RLock lock = redisson.getLock(lockKey);
        
        try {
            // 尝试获取锁,最多等待3秒,锁自动释放时间10秒
            if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
                // 双重检查
                userInfo = (UserInfo) redisTemplate.opsForValue().get(key);
                if (userInfo != null) {
                    return userInfo;
                }
                
                // 查询数据库
                userInfo = userMapper.selectById(userId);
                if (userInfo != null) {
                    // 写入缓存
                    redisTemplate.opsForValue().set(key, userInfo, 30, TimeUnit.MINUTES);
                }
                
                return userInfo;
            } else {
                // 获取锁失败,稍后重试或返回默认值
                log.warn("获取锁失败: {}", lockKey);
                Thread.sleep(50);
                return getUserById(userId); // 递归重试
            }
        } catch (InterruptedException e) {
            log.error("获取锁异常", e);
            Thread.currentThread().interrupt();
            return null;
        } finally {
            // 释放锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

解决方案2:热点数据永不过期

/**
 * 热点数据(如热门用户、热门礼物)设置为永不过期
 * 通过主动更新的方式刷新缓存
 */
public void refreshHotUserCache(Long userId) {
    String key = "user:info:" + userId;
    
    // 查询最新数据
    UserInfo userInfo = userMapper.selectById(userId);
    
    // 永不过期
    redisTemplate.opsForValue().set(key, userInfo);
    
    log.info("刷新热点用户缓存: userId={}", userId);
}

/**
 * 定时任务刷新热点数据
 */
@Scheduled(fixedRate = 300000) // 5分钟
public void refreshHotUsersTask() {
    // 获取热点用户列表(如在线用户、高活跃用户)
    List<Long> hotUserIds = getHotUserIds();
    
    for (Long userId : hotUserIds) {
        refreshHotUserCache(userId);
    }
}

6.3 缓存雪崩

问题: 大量缓存同时失效,数据库压力暴增

graph TD A[缓存雪崩] --> B[大量缓存同时过期] A --> C[Redis宕机] B --> D[解决方案1:随机过期时间] B --> E[解决方案2:缓存预热] C --> F[解决方案3:Redis集群] C --> G[解决方案4:降级策略]

解决方案1:随机过期时间

/**
 * 设置随机过期时间,避免同时失效
 */
public void setWithRandomExpire(String key, Object value, long baseMinutes) {
    // 基础时间 ± 10%
    long randomMinutes = baseMinutes + ThreadLocalRandom.current().nextLong(
            -baseMinutes / 10,
            baseMinutes / 10 + 1
    );
    
    redisTemplate.opsForValue().set(key, value, randomMinutes, TimeUnit.MINUTES);
}

解决方案2:缓存预热

/**
 * 系统启动时预热缓存
 */
@Component
public class CacheWarmer implements ApplicationListener<ContextRefreshedEvent> {
    
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        log.info("开始缓存预热...");
        
        // 1. 预热用户缓存
        warmUpUsers();
        
        // 2. 预热礼物缓存
        warmUpGifts();
        
        // 3. 预热配置缓存
        warmUpConfigs();
        
        log.info("缓存预热完成");
    }
    
    private void warmUpUsers() {
        // 预热活跃用户
        List<UserInfo> activeUsers = userMapper.selectActiveUsers(1000);
        for (UserInfo user : activeUsers) {
            String key = "user:info:" + user.getId();
            redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
        }
    }
}

解决方案3:多级缓存

/**
 * 多级缓存:本地缓存(Caffeine) + Redis
 */
@Service
public class MultiLevelCacheService {
    
    // 本地缓存
    private final Cache<String, Object> localCache = Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build();
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    public <T> T get(String key, Class<T> type, Supplier<T> dbLoader) {
        // 1. 查本地缓存
        T value = (T) localCache.getIfPresent(key);
        if (value != null) {
            log.debug("本地缓存命中: {}", key);
            return value;
        }
        
        // 2. 查Redis
        value = (T) redisTemplate.opsForValue().get(key);
        if (value != null) {
            log.debug("Redis缓存命中: {}", key);
            // 写入本地缓存
            localCache.put(key, value);
            return value;
        }
        
        // 3. 查数据库
        value = dbLoader.get();
        if (value != null) {
            // 写入Redis和本地缓存
            redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
            localCache.put(key, value);
        }
        
        return value;
    }
}

七、监控与降级

7.1 缓存监控指标

/**
 * 缓存监控服务
 */
@Service
@Slf4j
public class CacheMonitorService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private final AtomicLong hitCount = new AtomicLong(0);
    private final AtomicLong missCount = new AtomicLong(0);
    
    /**
     * 记录缓存命中
     */
    public void recordHit(String key) {
        hitCount.incrementAndGet();
        log.debug("缓存命中: {}", key);
    }
    
    /**
     * 记录缓存未命中
     */
    public void recordMiss(String key) {
        missCount.incrementAndGet();
        log.debug("缓存未命中: {}", key);
    }
    
    /**
     * 获取缓存命中率
     */
    public double getHitRate() {
        long total = hitCount.get() + missCount.get();
        if (total == 0) {
            return 0.0;
        }
        return (double) hitCount.get() / total * 100;
    }
    
    /**
     * 定时上报监控指标
     */
    @Scheduled(fixedRate = 60000) // 1分钟
    public void reportMetrics() {
        double hitRate = getHitRate();
        long hit = hitCount.getAndSet(0);
        long miss = missCount.getAndSet(0);
        
        log.info("缓存监控: 命中率={:.2f}%, 命中={}, 未命中={}", hitRate, hit, miss);
        
        // 上报到监控系统(Prometheus/Grafana)
        // metricsRegistry.gauge("cache.hit.rate", hitRate);
        
        // 命中率过低告警
        if (hitRate < 80) {
            alertService.send("缓存命中率过低: " + hitRate + "%");
        }
    }
}

7.2 缓存降级策略

/**
 * 缓存降级服务
 */
@Service
@Slf4j
public class CacheService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private CircuitBreakerRegistry circuitBreakerRegistry;
    
    /**
     * 带熔断的缓存查询
     */
    public <T> T getWithFallback(String key, Class<T> type, Supplier<T> dbLoader) {
        CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("redis");
        
        try {
            // 尝试查询缓存(受熔断器保护)
            return circuitBreaker.executeSupplier(() -> {
                T value = (T) redisTemplate.opsForValue().get(key);
                if (value != null) {
                    return value;
                }
                
                // 查数据库
                value = dbLoader.get();
                if (value != null) {
                    redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
                }
                return value;
            });
            
        } catch (Exception e) {
            log.error("Redis异常,降级查询数据库: key={}", key, e);
            
            // 降级:直接查数据库
            return dbLoader.get();
        }
    }
}

/**
 * 熔断器配置
 */
@Configuration
public class CircuitBreakerConfig {
    
    @Bean
    public CircuitBreakerRegistry circuitBreakerRegistry() {
        CircuitBreakerConfig config = CircuitBreakerConfig.custom()
                .failureRateThreshold(50) // 失败率50%触发熔断
                .waitDurationInOpenState(Duration.ofSeconds(30)) // 熔断30秒
                .slidingWindowSize(100) // 滑动窗口100个请求
                .build();
        
        return CircuitBreakerRegistry.of(config);
    }
}

八、最佳实践总结

8.1 缓存设计原则

/**
 * 缓存设计最佳实践
 */
public class CacheBestPractices {
    
    // ✅ 1. 缓存Key规范
    private static final String KEY_USER_INFO = "user:info:{userId}";
    private static final String KEY_USER_EXTEND = "user:extend:{userId}";
    private static final String KEY_GIFT_LIST = "gift:list:all";
    
    // ✅ 2. 过期时间设置
    private static final long EXPIRE_USER_INFO = 30; // 30分钟
    private static final long EXPIRE_HOT_DATA = -1; // 永不过期
    
    // ✅ 3. 空值缓存时间
    private static final long EXPIRE_NULL_VALUE = 5; // 5分钟
    
    // ✅ 4. 缓存大小限制
    private static final int MAX_CACHE_SIZE = 1024 * 1024; // 1MB
    
    // ✅ 5. 批量操作限制
    private static final int MAX_BATCH_SIZE = 500; // 单次最多500条
}

8.2 实战检查清单

# 缓存一致性保障清单

## 设计阶段
- [ ] 是否确定了缓存更新策略(Cache Aside/Read Through/Write Through)
- [ ] 是否选择了合适的一致性保障方案(延迟双删/MQ/Canal)
- [ ] 是否设计了缓存Key规范
- [ ] 是否评估了缓存大小和过期时间

## 开发阶段
- [ ] 是否先更新数据库再删除缓存
- [ ] 是否设置了缓存过期时间(兜底)
- [ ] 是否使用了随机过期时间(防雪崩)
- [ ] 是否处理了缓存删除失败的情况(重试/告警)
- [ ] 是否防护了缓存穿透(布隆过滤器/空值缓存)
- [ ] 是否防护了缓存击穿(分布式锁/热点数据永不过期)
- [ ] 是否批量查询避免循环(getUserMapByIds)

## 测试阶段
- [ ] 是否测试了并发更新场景
- [ ] 是否测试了Redis异常降级场景
- [ ] 是否测试了缓存穿透/击穿/雪崩场景
- [ ] 是否进行了压力测试

## 上线阶段
- [ ] 是否配置了监控告警(命中率/异常率)
- [ ] 是否准备了降级预案
- [ ] 是否进行了缓存预热
- [ ] 是否记录了应急处理文档

8.3 常见错误总结

错误 问题 正确做法
先删缓存再更新DB 并发读写导致旧数据入缓存 先更新DB再删缓存
不设置过期时间 缓存永久不一致 必须设置过期时间(兜底)
循环查询用户 N+1查询,性能差 批量查询转Map赋值
更新缓存而非删除 计算成本高,无效更新多 删除缓存,让读请求重新加载
不处理异常 Redis异常导致服务不可用 捕获异常,降级查DB
不做监控 问题发现不及时 监控命中率、异常率

附录

A. 相关依赖

<!-- Redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- Redisson(分布式锁) -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.17.7</version>
</dependency>

<!-- RocketMQ -->
<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-spring-boot-starter</artifactId>
    <version>2.2.2</version>
</dependency>

<!-- Canal -->
<dependency>
    <groupId>com.alibaba.otter</groupId>
    <artifactId>canal.client</artifactId>
    <version>1.1.7</version>
</dependency>

<!-- Caffeine(本地缓存) -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>

<!-- Resilience4j(熔断器) -->
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot2</artifactId>
    <version>1.7.1</version>
</dependency>

B. Redis配置

spring:
  redis:
    host: localhost
    port: 6379
    password: 
    database: 0
    lettuce:
      pool:
        max-active: 20
        max-idle: 10
        min-idle: 5
    timeout: 3000ms

# Redisson配置
redisson:
  single-server-config:
    address: redis://localhost:6379
    password: 
    connection-pool-size: 20
    connection-minimum-idle-size: 5

C. 参考资料


文档版本: v1.0
最后更新: 2026-01-16
维护团队: PartyPlay技术团队

posted @ 2026-01-16 13:40  菜鸟~风  阅读(0)  评论(0)    收藏  举报