如何保证Mysql和Redis双写一致性

如何保证Mysql和Redis双写一致性

这个问题本质上是分布式系统中的数据一致性问题 。因为 MySQL 和 Redis 是两个独立的存储系统,无法做到原子性更新,所以我们只能通过合理的更新策略来尽可能保证最终一致性,同时兼顾性能和可用性。

先明确:哪些方案是绝对不能用的 ❌

很多人一开始会踩这些坑,我先排除掉:

错误方案 致命问题
先更新 Redis,再更新 MySQL Redis 更新成功,MySQL 更新失败 → 数据永久不一致
先更新 MySQL,再更新 Redis 并发场景下会出现 "写覆盖" 问题,导致脏数据
双写都加分布式锁 性能极差,完全失去了 Redis 缓存的意义

业界主流的 4 种正确方案对比 📊

方案 1:先更新数据库,再删除缓存(最常用)

  • 优点:实现简单,性能好,出现不一致的概率极低
  • 缺点:极端情况下仍有不一致风险(数据库更新成功,删除缓存失败)
  • 适用场景:90% 以上的业务场景都可以用这个方案

方案 2:先删除缓存,再更新数据库

  • 优点:比 "先更库再删缓存" 更安全
  • 缺点:并发读场景下会出现 "缓存击穿" 问题
  • 解决办法:采用 "延迟双删" 策略

方案 3:更新数据库 + 消息队列异步删除缓存

  • 优点:解决了 "删除缓存失败" 的问题,有重试机制
  • 缺点:引入了 MQ 的复杂度,有一定的延迟
  • 适用场景:对一致性要求较高的业务

方案 4:基于 MySQL binlog 的最终一致性方案(终极方案)

  • 优点:完全解耦,业务代码无侵入,一致性最高
  • 缺点:架构最复杂,运维成本高
  • 适用场景:大型互联网公司,高并发高一致性要求的核心业务

面试必问:极端场景分析 🔍

场景 1:为什么是 "删除缓存" 而不是 "更新缓存"?

答案

  1. 并发写场景下,更新缓存会出现 "写覆盖" 问题
  2. 很多缓存值不是简单的数据库字段映射,计算成本高
  3. 采用 "懒加载" 思想,只有当缓存被读取时才会重新计算,节省资源

场景 2:"先更库再删缓存" 的极端不一致情况

发生条件

  1. 缓存刚好失效
  2. 线程 A 查询数据库,得到旧值
  3. 线程 B 更新数据库,然后删除缓存
  4. 线程 A 将旧值写入缓存

结果:缓存中永远是旧数据,直到下一次更新或过期

解决办法

  1. 给缓存设置合理的过期时间(兜底方案)
  2. 采用 "延迟双删" 策略
  3. 使用 binlog 异步删除方案

我的生产环境最佳实践 ✨

  1. 基础方案:先更新 MySQL,再删除 Redis 缓存
  2. 兜底方案:所有缓存都设置过期时间(15 分钟 - 2 小时)
  3. 增强方案:删除缓存失败时,通过 MQ 进行重试
  4. 终极方案:核心业务使用 Canal 监听 binlog 异步更新缓存

生产级核心代码实现 🚀

基于 Spring Boot 3.x + Redis 7.x + RabbitMQ 3.x

1 基础方案:先更库再删缓存(带异常重试)

技术亮点

  • 统一异常处理,删除失败立即重试 1 次
  • 异步删除不阻塞主业务流程
  • 日志埋点便于问题排查
java 复制代码
@Service
@Slf4j
public class UserService {
    @Autowired
    private UserMapper userMapper;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    // 自定义线程池,避免使用默认线程池导致OOM
    @Autowired
    private ThreadPoolTaskExecutor cacheExecutor;

    /**
     * 更新用户信息(基础双写方案)
     */
    @Transactional(rollbackFor = Exception.class)
    public void updateUser(User user) {
        // 1. 先更新数据库
        int rows = userMapper.updateById(user);
        if (rows == 0) {
            log.warn("更新用户信息失败,用户不存在: {}", user.getId());
            return;
        }

        // 2. 异步删除缓存,不阻塞主流程
        String cacheKey = "user:info:" + user.getId();
        cacheExecutor.execute(() -> {
            try {
                redisTemplate.delete(cacheKey);
                log.info("删除缓存成功: {}", cacheKey);
            } catch (Exception e) {
                // 立即重试1次,仍失败则记录告警,后续由定时任务兜底
                log.error("第一次删除缓存失败,重试中: {}", cacheKey, e);
                try {
                    redisTemplate.delete(cacheKey);
                    log.info("重试删除缓存成功: {}", cacheKey);
                } catch (Exception ex) {
                    log.error("重试删除缓存失败,需人工介入: {}", cacheKey, ex);
                    // 发送告警(邮件/短信/钉钉)
                    alertService.sendAlert("缓存删除失败", cacheKey);
                }
            }
        });
    }
}

2 增强方案:延迟双删(解决并发读写脏数据)

技术亮点

  • 使用线程池实现延迟任务,不阻塞主线程
  • 可配置延迟时间,适配不同数据库同步延迟
  • 幂等性检查,避免重复删除
java 复制代码
@Service
@Slf4j
public class UserService {
    // 省略其他注入...
    @Value("${cache.delay-delete-time:500}")
    private long delayDeleteTime;

    /**
     * 更新用户信息(延迟双删方案)
     */
    @Transactional(rollbackFor = Exception.class)
    public void updateUserWithDelayDelete(User user) {
        String cacheKey = "user:info:" + user.getId();
        
        // 1. 第一次删除缓存
        redisTemplate.delete(cacheKey);
        
        // 2. 更新数据库
        userMapper.updateById(user);
        
        // 3. 延迟删除缓存(核心:等待读线程完成旧值写入)
        cacheExecutor.schedule(() -> {
            // 幂等性检查:如果缓存不存在,无需删除
            if (Boolean.TRUE.equals(redisTemplate.hasKey(cacheKey))) {
                redisTemplate.delete(cacheKey);
                log.info("延迟删除缓存成功: {}", cacheKey);
            }
        }, delayDeleteTime, TimeUnit.MILLISECONDS);
    }
}

3 高可靠方案:MQ 异步删除(解决删除失败问题)

技术亮点

  • 消息持久化 + 重试机制,保证最终一致性
  • 幂等性设计,防止重复消费
  • 死信队列处理失败消息,避免消息丢失
java 复制代码
// 生产者
@Service
@Slf4j
public class CacheDeleteProducer {
    @Autowired
    private RabbitTemplate rabbitTemplate;

    public void sendDeleteMessage(String cacheKey) {
        try {
            // 消息体包含唯一ID,用于幂等性
            CacheDeleteMessage message = new CacheDeleteMessage(
                UUID.randomUUID().toString(),
                cacheKey
            );
            rabbitTemplate.convertAndSend("cache-exchange", "cache.delete", message);
            log.info("发送删除缓存消息成功: {}", message);
        } catch (Exception e) {
            log.error("发送删除缓存消息失败: {}", cacheKey, e);
            throw new RuntimeException("发送缓存删除消息失败", e);
        }
    }
}

// 消费者
@Component
@Slf4j
public class CacheDeleteConsumer {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @RabbitListener(queues = "cache-delete-queue")
    public void handleDeleteMessage(CacheDeleteMessage message) {
        String cacheKey = message.getCacheKey();
        String messageId = message.getMessageId();
        
        // 1. 幂等性检查:如果该消息已处理过,直接返回
        String idempotentKey = "cache:delete:idempotent:" + messageId;
        if (Boolean.TRUE.equals(redisTemplate.hasKey(idempotentKey))) {
            log.info("消息已处理,跳过: {}", messageId);
            return;
        }

        try {
            // 2. 删除缓存
            redisTemplate.delete(cacheKey);
            log.info("消费消息删除缓存成功: {}", cacheKey);
            
            // 3. 标记消息已处理,过期时间24小时
            redisTemplate.opsForValue().set(idempotentKey, "1", 24, TimeUnit.HOURS);
        } catch (Exception e) {
            log.error("消费消息删除缓存失败: {}", message, e);
            // 抛出异常,触发RabbitMQ重试机制
            throw new RuntimeException("处理缓存删除消息失败", e);
        }
    }
}

// 消息实体
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CacheDeleteMessage implements Serializable {
    private String messageId;
    private String cacheKey;
}

4 终极方案:Canal 监听 binlog 异步更新(业务无侵入)

技术亮点

  • 完全解耦业务代码,无需在业务层处理缓存
  • 基于数据库 binlog,保证数据变更不丢失
  • 支持分库分表场景下的缓存同步
java 复制代码
// Canal客户端核心代码
@Component
@Slf4j
public class CanalBinlogListener {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @PostConstruct
    public void startCanalClient() {
        // 创建Canal连接
        CanalConnector connector = CanalConnectors.newSingleConnector(
            new InetSocketAddress("127.0.0.1", 11111),
            "example",
            "",
            ""
        );

        try {
            connector.connect();
            // 订阅所有库所有表
            connector.subscribe(".*\\..*");
            connector.rollback();

            while (true) {
                // 获取binlog数据
                Message message = connector.getWithoutAck(100);
                long batchId = message.getId();
                int size = message.getEntries().size();

                if (batchId == -1 || size == 0) {
                    Thread.sleep(1000);
                    continue;
                }

                // 解析binlog
                for (CanalEntry.Entry entry : message.getEntries()) {
                    if (entry.getEntryType() == CanalEntry.EntryType.ROWDATA) {
                        CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
                        CanalEntry.EventType eventType = rowChange.getEventType();

                        // 只处理更新和删除操作
                        if (eventType == CanalEntry.EventType.UPDATE || eventType == CanalEntry.EventType.DELETE) {
                            String tableName = entry.getHeader().getTableName();
                            for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
                                // 获取主键ID
                                String id = getPrimaryKey(rowData, tableName);
                                String cacheKey = tableName + ":info:" + id;
                                
                                // 删除缓存
                                redisTemplate.delete(cacheKey);
                                log.info("Canal删除缓存成功: {}", cacheKey);
                            }
                        }
                    }
                }

                // 确认消息
                connector.ack(batchId);
            }
        } catch (Exception e) {
            log.error("Canal客户端异常", e);
        } finally {
            connector.disconnect();
        }
    }

    // 获取表主键
    private String getPrimaryKey(CanalEntry.RowData rowData, String tableName) {
        // 根据不同表获取主键,这里以user表为例
        for (CanalEntry.Column column : rowData.getBeforeColumnsList()) {
            if (column.getName().equals("id")) {
                return column.getValue();
            }
        }
        throw new RuntimeException("未找到主键: " + tableName);
    }
}

核心技术难点与解决方案汇总 🧩

技术难点 问题描述 解决方案 技术亮点
删除缓存失败 网络波动、Redis 宕机导致删除操作失败,数据永久不一致 1. 立即重试 1 次2. MQ 异步重试(最多 3 次)3. 定时任务兜底扫描过期缓存4. 死信队列告警人工介入 多级重试机制,最终一致性兜底
并发读写脏数据 缓存失效时,读线程拿到旧值,写线程更新后删除缓存,读线程再把旧值写入缓存 1. 给缓存设置过期时间(兜底)2. 延迟双删策略3. 读写锁(读多写少场景) 延迟双删成本最低,效果最好
延迟时间难以确定 延迟双删的延迟时间太短,读线程还没写完;太长影响一致性 1. 压测数据库平均查询耗时,设置为其 2-3 倍2. 配置中心动态调整,无需重启服务3. 核心业务使用 Canal 方案 动态配置 + 压测数据支撑,灵活调整
MQ 消息丢失 / 重复消费 MQ 宕机、网络分区导致消息丢失;重试机制导致重复消费 1. 消息持久化 + 生产者确认机制2. 消费者手动 ACK3. 基于消息 ID 的幂等性设计4. 死信队列处理失败消息 幂等性是解决重复消费的根本
大 key 删除阻塞 Redis 缓存大 key(如列表、哈希)删除时会阻塞 Redis 主线程 1. Redis 4.0 + 使用 UNLINK 命令异步删除2. 大 key 拆分,分批删除3. 避免缓存大对象 异步删除不阻塞主线程,保证 Redis 可用性
缓存穿透 / 击穿 / 雪崩 双写一致性问题常伴随这些缓存问题,加剧数据不一致 1. 布隆过滤器防穿透2. 互斥锁防击穿3. 缓存过期时间加随机值防雪崩 组合使用多种策略,全面防护
分布式事务问题 数据库更新和缓存删除无法原子性执行 1. 放弃强一致性,追求最终一致性2. 基于 Seata 的 TCC 模式(不推荐,性能差)3. 基于 binlog 的 CDC 方案(推荐) CDC 方案完全解耦,性能最好
分库分表场景 分库分表后,数据变更分散在多个库,缓存同步复杂 1. Canal 监听所有分库的 binlog2. 统一缓存 key 命名规范3. 按表维度拆分缓存同步逻辑 业务无感知,支持水平扩展

面试终极加分项 💎

  1. 能说出 "先更库再删缓存" 的极端不一致场景发生的概率极低的原因:需要同时满足 "缓存刚好失效 + 读线程查询慢 + 写线程更新快" 三个条件
  2. 能解释为什么不推荐使用更新缓存:除了写覆盖问题,还有缓存利用率低、计算成本高的问题
  3. 能结合自己的项目经验,说明不同业务场景下的方案选型:比如非核心业务用基础方案,核心业务用 MQ+Canal 方案
  4. 能提到降级策略:当 Redis 不可用时,直接读数据库,保证业务可用性
  5. 能说出监控指标:缓存命中率、缓存删除失败率、MQ 消息堆积量、Canal 同步延迟

真实面试模拟

真实面试模拟

面试官 😊:

"你在项目里肯定用过 Redis 做缓存吧,那如果让你设计一个高并发的读写场景,怎么保证 MySQL 和 Redis 双写一致性?先说说你的思路。"

候选人 🤔:

"好的面试官。这个问题我们线上实际踩过坑,后来沉淀了一套方案。我先说两个最典型的错误做法,因为真实面试里直接说出正确方案前,理解为什么错更重要。"

面试官 👍:

"可以,说说看。"

候选人 💣:

"第一个坑是 '先删缓存,再更新数据库'

我画个时序图就清楚了:"

"您看,读请求恰好插在删除缓存和更新DB之间,把旧数据刷回了缓存,导致长时间脏数据。这个在高并发下很容易复现。"

候选人 🔥:

"第二个坑是 '先更新数据库,再更新缓存'

并发写会有顺序问题:"

"最终缓存里是10,DB是20,不一致。而且更新缓存还涉及序列化开销和写竞争,所以我们基本放弃了更新缓存的思路。"

面试官 💡:

"那你们线上最终怎么落地?"

候选人 ✨:

"核心是 Cache Aside 模式的一个变种------流程只有两步:

  1. 先更新数据库
  2. 再删除缓存(注意是删除,不是更新)

读请求则保持 查缓存 → miss → 查DB → 写回缓存,并给所有缓存设置过期时间兜底。

为什么删除而不是更新?因为删除是幂等的,避免并发写覆盖,而且让数据惰性加载,不读就不占内存。"

面试官 🧐:

"先更DB再删缓存,有没有极端情况仍然不一致?"

候选人 👇:

"有,不过概率极低。需要读写并发且时序恰好错位:

  • 读请求缓存miss,读到DB旧数据(此时写请求还没删缓存)
  • 写请求更新DB,删除缓存
  • 读请求把刚才拿到的旧数据写回缓存

这个窗口非常窄,但我们还是做了保护------延迟双删。"

面试官 ⏱️:

"延迟双删怎么实现?同步 sleep 吗?"

候选人 🛠️:

"绝对不能同步 sleep,会阻塞主线程。我们用的是 线程池异步,流程是:

text 复制代码
更新DB → 删缓存 → 提交异步任务(休眠300ms) → 再次删除缓存

如果第二次删缓存失败,投递到消息队列做重试,保证最终一定删掉。

300ms 是根据接口响应时间 P99 定的,基本能覆盖读请求写回缓存的时间。"

面试官 🚀:

"如果整个系统缓存更新逻辑很复杂,或者跨多个服务,光靠删缓存不够怎么办?"

候选人 🌊:

"我们上了 基于 MySQL Binlog 的异步更新方案

业务代码只写数据库,完全不管缓存。Canal 订阅 binlog,投递到 RocketMQ,由专门的缓存更新服务消费,架构图是这样的:"

"优点就是业务零侵入,MQ 重试 + 死信队列保证了最终一致。缺点是有百毫秒级延迟,只适合最终一致性场景。"

面试官 ⚖️:

"那如果涉及资金、库存这种,完全不能忍受短暂不一致的呢?"

候选人 🎯:

"这种场景我们就不追求缓存强一致了。

做法是:

  • 把 Redis 当作只读缓存,TTL 设得很短(比如1秒),权威数据永远在 MySQL。
  • 写操作直接走 DB,读操作在缓存 miss 时查 DB,用分布式锁避免热点 key 的缓存击穿。
  • 真正的一致性靠数据库事务保证,Redis 只是性能层,不参与一致性的核心逻辑。"

面试官 📝:

"总结一下你的设计思路?"

候选人 🌟:

"三句话总结吧:

  1. 🚫 绝不更新缓存,只删除缓存,用删除的幂等性避免并发写覆盖。
  2. 所有缓存都要有过期时间,即使所有同步机制都失败,过期时间也能兜底。
  3. 📭 异步重试 + 消息补偿,保证最终一致性,别为了强一致牺牲可用性。

方案选型看业务容忍度:

场景 方案 一致性
普通读多写少 先更DB + 删缓存 + 延迟双删 最终一致(窗口极小)
跨系统/复杂更新 Binlog + MQ 异步刷新 最终一致(百毫秒延迟)
资金/库存核心链路 短TTL缓存 + 依赖DB事务 强一致

以上就是我们生产环境验证过的整套思路。"

📌 核心代码片段

🥇 延迟双删(异步 + 兜底重试)

java 复制代码
@Service
public class CacheAsideService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private ThreadPoolTaskExecutor delayDeleteExecutor;
    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    /**
     * 先更新DB,再删除缓存,并异步延迟双删
     */
    @Transactional(rollbackFor = Exception.class)
    public void updateDataAndDeleteCache(String key, Object newData) {
        // 1. 更新 MySQL(事务保证)
        updateDatabase(newData);
        
        // 2. 第一次删除缓存
        redisTemplate.delete(key);
        
        // 3. 提交异步延迟二次删除
        delayDeleteExecutor.execute(() -> {
            try {
                Thread.sleep(300); // 休眠时间依据接口P99延迟设定
                redisTemplate.delete(key);
            } catch (Exception e) {
                // 4. 二次删除失败,投递MQ做最终补偿
                rocketMQTemplate.convertAndSend("cache-delete-retry-topic", key);
            }
        });
    }
}

💡 亮点

  • 删除动作脱离事务边界,但通过 @Transactional 保证 DB 更新成功后再删缓存。
  • 异步线程池隔离,不阻塞业务线程。
  • MQ 补偿保证"最终一定会删掉",避免遗漏。

🥈 Binlog 消费者幂等处理

java 复制代码
@Component
@RocketMQMessageListener(topic = "binlog-cache-sync", consumerGroup = "cache-sync-group")
public class BinlogCacheConsumer implements RocketMQListener<MessageExt> {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private BloomFilterManager bloomFilterManager; // 自定义布隆过滤器

    @Override
    public void onMessage(MessageExt msg) {
        BinlogEvent event = JSON.parseObject(msg.getBody(), BinlogEvent.class);
        String cacheKey = event.getCacheKey();
        
        // 幂等设计:用唯一消息ID+布隆过滤器防重
        String msgId = msg.getMsgId();
        if (bloomFilterManager.mightContain("consumed_msg", msgId)) {
            // 可能已消费,去Redis精确校验
            if (redisTemplate.opsForValue().get("msg_consumed:" + msgId) != null) {
                return;
            }
        }
        
        // 处理缓存更新/删除
        if (event.getType() == EventType.DELETE) {
            redisTemplate.delete(cacheKey);
        } else {
            redisTemplate.opsForValue().set(cacheKey, event.getData(), 30, TimeUnit.MINUTES);
        }
        
        // 标记消费完成(布隆过滤器+Redis双重记录)
        bloomFilterManager.put("consumed_msg", msgId);
        redisTemplate.opsForValue().set("msg_consumed:" + msgId, "1", 2, TimeUnit.HOURS);
    }
}

💡 亮点

  • 布隆过滤器 + Redis 双重保障幂等,极小内存开销下保证重复消费不会造成脏数据。
  • 消费失败由 RocketMQ 自动重试,配合死信队列兜底人工介入。
  • 操作都是幂等的(deleteset),即使极端情况重复消费也安全。

🥉 热点 key 防击穿(分布式锁 + 缓存空对象)

java 复制代码
public Object getDataWithCache(String key) {
    Object value = redisTemplate.opsForValue().get(key);
    if (value != null) return value;
    
    // 分布式锁,防止热点key击穿数据库
    String lockKey = "lock:" + key;
    boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
    if (locked) {
        try {
            // 双重检查
            value = redisTemplate.opsForValue().get(key);
            if (value != null) return value;
            
            // 查DB
            value = queryFromDatabase(key);
            // 即使值为null也缓存空对象,防止缓存穿透
            redisTemplate.opsForValue().set(key, value == null ? "null" : value, 
                                            value == null ? 1 : 30, TimeUnit.MINUTES);
            return value;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // 没拿到锁,短暂等待后重试
        Thread.sleep(50);
        return getDataWithCache(key);
    }
}

💡 亮点

  • SETNX 轻量分布式锁,避免缓存击穿。
  • 缓存空值防穿透,TTL 短(1分钟)减少内存占用。
  • 锁过期时间 5s 兜底,避免死锁。

⚔️ 技术难点与解决方案

难点 问题描述 我们的解法
并发写覆盖 先更DB再更缓存,乱序导致旧值覆盖新值 🚫 绝不更新缓存,只删除,用删除的幂等性规避
读写并发脏数据 先更DB后删缓存间隙,读请求把旧数据写回 🎯 延迟双删 + 异步补偿MQ
删除缓存失败 网络抖动或Redis故障导致删除失败,脏数据永存 📬 异步重试 + MQ死信队列,保证"最终必删"
Binlog消费顺序 同一个key的更新顺序可能被多分区打乱 🔒 按key哈希分区,保证同一key的消息由同一个消费者顺序处理
Binlog消费幂等 RocketMQ至少一次投递导致重复消费 🌸 布隆过滤器 + Redis标记,双重幂等
缓存穿透 恶意查询不存在的数据,直接打到DB 🛡️ 缓存空对象 + 布隆过滤器前置拦截
缓存击穿 热点key过期瞬间大量请求涌向DB 🔑 分布式锁(SETNX) + 双重检查
缓存雪崩 大量key同时过期,DB瞬间压力过大 ⏱️ TTL加随机偏移量,不设相同过期时间
短暂不一致窗口 最终一致方案下,用户可能读到旧数据 🧘 业务可接受 + 短TTL兜底,核心链路直接读DB

面试中我会特别强调:"设计的一致性方案,本质是用空间/复杂度,换业务可接受的最终一致 "。没有绝对完美的强一致,只有与场景匹配的权衡。我们代码里每一个 Thread.sleep(300)、每一个 MQ 补偿,都是在和分布式的不确定性作斗争 💪。

相关推荐
Rain的Java大神实战圈1 天前
介绍一下Redis的缓存穿透击穿雪崩
场景设计题
Rain的Java大神实战圈2 天前
对千万级单表进行分页查询
场景设计题
Rain的Java大神实战圈3 天前
MySQL建表时需要注意什么
场景设计题
Rain的Java大神实战圈6 天前
千万级大表如何新增字段
场景设计题
Rain的Java大神实战圈8 天前
日增量500万的表结构如何设计
场景设计题
Rain的Java大神实战圈10 天前
数据库主从有延迟怎么处理
架构设计·场景设计题
Rain的Java大神实战圈14 天前
分库分表会带来哪些问题如何解决
架构设计·场景设计题
Rain的Java大神实战圈15 天前
介绍一下分库分表
架构设计·场景设计题
Rain的Java大神实战圈16 天前
线上慢SQL的排查思路
经验分享·架构设计·场景设计题