← 每日面试题 学习看板

Java 后端模拟面试题

面试题/高阶题目/2026-09-15-数据库-性能-架构.md

Java 后端模拟面试题

日期:2026-09-15

总题数:3 题(进阶 2 题 + 高阶 1 题)

方向覆盖:数据库优化、性能优化、架构设计


R1 数据库优化 | 进阶

题目

场景:某电商系统订单表 orders 单表 5 亿行,单库单表已无法支撑业务增长。查询 P99 延迟从 50ms 飙到 2s,DB CPU 长期 90%+,插入 TPS 3000/s 导致主从延迟 30s+。

问题:

  1. 如何设计分库分表方案?分片键选什么?为什么?
  2. 如何迁移 5 亿行历史数据?如何保证迁移期间不停机?
  3. 迁移完成后,如何验证数据一致性?如何回滚?

原始回答

首先第一个是分库分表意见。通过当前数据量跟数据表情况,订单表的话,正常查询的方式是以人、用户、时间维度。所以拆分的话,最好是以时间维度去作为分片键。用人和用用户维度的话,作为数分片键的话,查询很难通过一个单个查询去查询结果。而以人跟用商铺的查询,可以创建关联子表去冗余数据,然后去进行对应的查询跟统计。然后对于历史数据的迁移,首先 5 亿历史数据都有有价值吗;这 5 亿数据都有必要迁移吗?正常来说,历史大量的历史数据应该作为冷数据处理,放到冷存储里边,没有必要的查询。正常的话查询也会是统计性的查询,不会查 5 亿所有的历史数据,这些数据可以放到,比如 ES 啊,或者是做归档数据啊,这种,然后做历史查询,而不是去整体,这样我们可迁移的数据应该是最近 3 个月的,或者是最近一段时间有效期的,而不会全量的。非全量数据的话进行迁移,首先是把近期的已经确定状态的数据,因为订单本身是有一个订单自身状态的,订单状态不可变的数据首先可以进行直接的迁移移过去,这部分数据也不会有什么变动。其次的话还会有变动的这些数据可以增量的去迁移,把数据进行一点点的迁过去,同增量同步去做处理。迁移完后数据同步性。首先在迁移完成后,先保证两个库同步运行。然后对两个库在使用迁移工具做两个库的增量同步,然后进行数据扫描,确认两边的数据一致性。如果出现异常,不可能是回滚,是保证两个库是可以一直先使用主库,确认一下新库是否存在,存在哪些问题。回滚的话,所谓回滚应该就是使用原库,而不是说把数据回滚什么情况。这就是我的方案。

点评

优点:

不足:


完整讲解

子问题 1:分库分表方案设计

1.1 分片键选择
分片键优点缺点适用场景
user_id用户查询只路由 1 个分片热门用户数据倾斜订单、用户相关查询
create_time数据均匀分布用户查询命中所有分片时序数据、日志、监控
order_no订单号查询路由 1 个分片用户查询命中所有分片订单号查询场景

推荐:user_id hash 分片

为什么选 user_id:
1. 用户查询("我的订单")是最频繁场景 → 只查 1 个分片
2. 用户 ID 分布相对均匀(除非有超级用户)
3. 按 create_time 分片,用户查询要查 12+ 个分片,性能更差

跨分片查询解决:

查询类型解决方案
用户订单查询按 user_id 分片,1 个分片搞定
时间范围查询(运营报表)同步到 ES/数仓,不走分片
订单号查询(客服)建全局索引表,order_no → user_id 映射
状态查询(超时订单)建状态路由表,status → 分片列表
-- 全局索引表(不分片,单独一张表)
CREATE TABLE order_index (
    order_no VARCHAR(64) PRIMARY KEY,
    user_id BIGINT NOT NULL,
    shard_table VARCHAR(32) NOT NULL
);

-- 查询流程:
-- 1. 查 order_index 得到 user_id + shard_table
-- 2. 路由到对应分片表查数据
1.2 分片方案
# ShardingSphere-JDBC 配置
sharding:
  tables:
    orders:
      actualDataNodes: ds0.orders_${0..3}
      tableStrategy:
        standard:
          shardingColumn: user_id
          shardingAlgorithmClassName: com.example.OrderShardingAlgorithm

# 分 4 张表,user_id % 4
# 可扩展到 16/32/64 张表

# 全局索引表(不分片)
globalTables:
  order_index:
    actualDataNodes: ds0.order_index

分片算法:

public class OrderShardingAlgorithm implements PreciseShardingAlgorithm<Long> {
    
    @Override
    public String doSharding(
        Collection<String> tableNames, 
        PreciseShardingValue<Long> shardingValue
    ) {
        Long userId = shardingValue.getValue();
        int index = (int) (Math.abs(userId) % 4);
        return "orders_" + index;
    }
}
1.3 数据量规划
分片数据量说明
orders_0~1.25 亿user_id % 4 = 0
orders_1~1.25 亿user_id % 4 = 1
orders_2~1.25 亿user_id % 4 = 2
orders_3~1.25 亿user_id % 4 = 3
order_index~5 亿全局索引表

冷热分离:

热数据(近 6 个月):~3 亿行 → 分片表
冷数据(6 个月前):~2 亿行 → 归档表(不分片)

子问题 2:历史数据迁移方案

2.1 迁移策略:双写 + 灰度切流
阶段 1:准备(1 周)
  - 部署分片中间件(ShardingSphere)
  - 创建分片表 + 全局索引表
  - 部署 Canal 监听 binlog
  - 开发迁移工具 + 校验工具

阶段 2:双写(1-2 周)
  - 应用层同时写旧表 + 新分片表
  - Canal 监听 binlog 增量同步
  - 灰度 1% 流量走新库

阶段 3:数据迁移(1 周)
  - 全量迁移历史数据(按 user_id 范围分批)
  - 增量同步实时追平
  - 灰度 10% → 50% → 100%

阶段 4:切换(1 天)
  - 100% 流量走新库
  - 旧库保持只读(回滚兜底)

阶段 5:下线(1 周后)
  - 确认无问题,下线旧库
2.2 双写实现
@Service
public class OrderDualWriteService {
    
    @Autowired
    private OrderDao oldOrderDao;
    @Autowired
    private OrderDao newOrderDao;
    
    @Transactional
    public void createOrder(OrderRequest req) {
        // 1. 写旧表(主库)
        oldOrderDao.insert(req);
        
        // 2. 写新分片表(异步,不阻塞)
        CompletableFuture.runAsync(() -> {
            try {
                newOrderDao.insert(req);
                orderIndexDao.insert(buildIndex(req));
            } catch (Exception e) {
                log.error("New table write failed, need compensation", e);
                failedWriteDao.insert(req);
            }
        });
    }
    
    public boolean shouldUseNewTable(Long userId) {
        int hash = (int) (userId % 100);
        return hash < grayRatio;
    }
}
2.3 增量同步(Canal)
@CanalInstance("orders_migration")
public class OrderCanalListener implements CanalMessageListener {
    
    @Override
    public void onMessage(CanalEntry.Entry entry) {
        CanalEntry.RowChange rowChange = entry.getRowChange();
        
        switch (rowChange.getHeader().getEventType()) {
            case INSERT:
                Order order = parseOrder(rowChange, CanalEntry.EntryType.INSERT);
                newOrderDao.insert(order);
                orderIndexDao.insert(buildIndex(order));
                break;
            case UPDATE:
                Order updated = parseOrder(rowChange, CanalEntry.EntryType.UPDATE);
                newOrderDao.update(updated);
                break;
            case DELETE:
                Order deleted = parseOrder(rowChange, CanalEntry.EntryType.DELETE);
                newOrderDao.delete(deleted);
                break;
        }
    }
}

子问题 3:数据验证 + 回滚方案

3.1 数据验证
// 验证 1:行数对比
@Scheduled(cron = "0 0 * * * ?")
public void verifyRowCount() {
    long oldCount = oldOrderDao.count();
    long newCount = newOrderDao.count() + orderIndexDao.count();
    
    if (oldCount != newCount) {
        alertService.warn("Data migration row count mismatch");
    }
}

// 验证 2:Checksum 校验
@Scheduled(cron = "0 0 * * * ?")
public void verifyChecksum() {
    for (int shardIndex = 0; shardIndex < 4; shardIndex++) {
        String oldChecksum = oldOrderDao.checksumByShard(shardIndex);
        String newChecksum = newOrderDao.checksumByShard(shardIndex);
        
        if (!oldChecksum.equals(newChecksum)) {
            log.warn("Checksum mismatch: shard={}", shardIndex);
            detailedCompare(shardIndex);
        }
    }
}

// 验证 3:抽样校验
@Scheduled(cron = "0 30 * * * ?")
public void verifySample() {
    List<Long> sampleOrderIds = oldOrderDao.sampleOrderIds(1000);
    
    int mismatchCount = 0;
    for (Long orderId : sampleOrderIds) {
        Order oldOrder = oldOrderDao.selectById(orderId);
        Order newOrder = newOrderDao.selectById(orderId);
        
        if (!Objects.equals(oldOrder, newOrder)) {
            mismatchCount++;
            mismatchRecordDao.insert(orderId, oldOrder, newOrder);
        }
    }
    
    double mismatchRate = (double) mismatchCount / sampleOrderIds.size();
    if (mismatchRate > 0.001) {
        alertService.warn("Data mismatch rate too high: " + mismatchRate);
    }
}
3.2 回滚方案

回滚触发条件:

条件阈值动作
数据不一致差异率 > 0.1%暂停切流,排查问题
新库性能差P99 > 原库 2 倍回滚到旧库
业务异常报错率 > 1%回滚到旧库
数据丢失发现数据丢失立即回滚

回滚流程:

public void rollbackMigration() {
    // 1. 停止双写新库
    dualWriteConfig.setGrayRatio(0);
    
    // 2. 确认旧库数据完整(Canal 已同步)
    verifyOldTableComplete();
    
    // 3. 暂停 Canal 同步(避免冲突)
    canalService.pause();
    
    // 4. 流量全部走旧库
    trafficConfig.setUseNewTable(false);
    
    // 5. 告警 + 记录
    alertService.error("Migration rolled back");
    rollbackLogDao.insert();
}

反向同步(切换期间):

@CanalInstance("orders_reverse_sync")
public class ReverseSyncListener implements CanalMessageListener {
    
    @Override
    public void onMessage(CanalEntry.Entry entry) {
        // 新分片表的变更 → 同步到旧表
        CanalEntry.RowChange rowChange = entry.getRowChange();
        switch (rowChange.getHeader().getEventType()) {
            case INSERT:
                Order order = parseOrder(rowChange);
                oldOrderDao.insert(order);
                break;
            case UPDATE:
                Order updated = parseOrder(rowChange);
                oldOrderDao.update(updated);
                break;
            case DELETE:
                Order deleted = parseOrder(rowChange);
                oldOrderDao.delete(deleted);
                break;
        }
    }
}

总结

子问题核心方案
1. 分库分表方案user_id hash 分片(4 张表)+ 全局索引表 + 冷热分离
2. 历史数据迁移双写 + Canal 增量同步 + 灰度切流(1% → 10% → 50% → 100%)
3. 验证 + 回滚行数对比 + Checksum + 抽样校验,回滚靠反向同步保证旧库完整

R2 性能优化 | 进阶

题目

场景:某 Java 服务上线一个月后堆内存从 20% 涨到 95%,Full GC 从 0 次/天涨到 20 次/天,STW 从 50ms 涨到 3s。运维每天凌晨 3 点手动重启一次才能恢复。

问题:

  1. 如何定位内存泄漏?给出工具链和关键步骤。
  2. 列出 5 个常见的 Java 内存泄漏场景,并给出修复示例。
  3. 如何建立内存监控预警,在重启前自动发现泄漏?

原始回答

内存泄漏定位的话,可以分析使用内存,加 JVM 内存参数,去在它的内存 GC,或者是出现问题 OM 的时候,进行输出服务器的内存镜像,然后把当前的内存情况具体去查看。另外可以使用 Arris 去看它的热点图,看当前对象的具体情况是什么样子的,哪些对象有巨大的热点,来去观察具体内存占用情况。还有 jmap 看当前占据的使用的内存的情况是什么样子的,IIS 里边也有可以看。 第二个内存泄露场景。首先可能是引用三方对象没有回收,引入对象没有回收。然后可能是 Timer、ThreadLocal 内存泄露,也可能是对象的 IO 没有关,IO 对象流没有关。可能线程池、ThreadLocal 的使用线程池了,然后没有没有被回收。具体可能是什么原因,可以根据实际的内存的镜像去查看,跟内存的那个内存的对象分布去观察,具体确认是什么原因导致的。内存监控预警的话,可以设立监控报警,服务器的自己监控报警,监控 ZLM 指标。然后当超过一定比例的时候,并且回收之后,回收差额出现多次回收回收不掉的时候,这时候就去发生告警信息,从而达到监控发生泄露的这么个情况。

点评

优点:

不足:


完整讲解

子问题 1:内存泄漏定位步骤

1.1 场景分析
Old 区从 30% 线性增长到 90%,每天增长 6%
    ↓
Full GC 20 次/天,STW 3s
    ↓
流量平稳,无业务高峰
    ↓
近期变更:新增"用户行为分析"模块 + 3 个第三方 SDK
    ↓
最大嫌疑:第三方 SDK 内部缓存/监听器/静态集合未释放

关键线索:内存线性增长(不是阶梯式),说明每次 GC 都回收不了,对象持续存活。

1.2 定位工具链
Step 1: 确认泄漏(看趋势)
    ↓
Step 2: 导出 heap dump(2 次,间隔 30 分钟)
    ↓
Step 3: MAT 分析(Dominator Tree + GC Root)
    ↓
Step 4: 定位泄漏对象(静态集合/监听器/类加载器)
    ↓
Step 5: 修复 + 验证
1.3 关键命令
# Step 1: 确认泄漏趋势
jstat -gcold <pid> 1000 10

# Step 2: 导出 heap dump(2 次)
jmap -dump:format=b,file=/tmp/heap_1.hprof <pid>
# 等待 30 分钟
jmap -dump:format=b,file=/tmp/heap_2.hprof <pid>

# 或 Arthas 导出
heapdump --live /tmp/heap.hprof
1.4 MAT 分析(核心)

分析路径:

1. Leak Suspects Report → 自动分析可疑对象
2. Dominator Tree → 按占用大小排序
3. Path to GC Roots → 看谁在持有引用
4. Compare Snapshots → 对比两次 dump,找出增长的对象

关键判断:

现象结论修复方向
大量 HashMap 实例,GC Root 指向静态字段静态集合泄漏用 Caffeine/Guava Cache 带过期策略
大量 ThreadLocalMap.Entry,线程池线程未清理ThreadLocal 泄漏try-finally remove()
大量第三方 SDK 对象,GC Root 指向监听器监听器泄漏注销监听器(removeListener)
多个 ClassLoader 实例,类加载器未卸载类加载器泄漏避免动态类加载,确保 ClassLoader 可回收
大量 Socket/InputStream 未关闭IO 资源泄漏try-with-resources

子问题 2:5 个常见内存泄漏场景 + 修复

2.1 场景 1:静态集合无限增长
// 问题:静态 Map 缓存,没有过期策略
public class UserCache {
    private static final Map<Long, User> CACHE = new HashMap<>();
    
    public static User getUser(Long userId) {
        User user = CACHE.get(userId);
        if (user == null) {
            user = db.query(userId);
            CACHE.put(userId, user);  // 无限增长!
        }
        return user;
    }
}

// 修复:用 Caffeine Cache
public class UserCache {
    private static final Cache<Long, User> CACHE = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(30))
        .recordStats()
        .build();
    
    public User getUser(Long userId) {
        User user = CACHE.getIfPresent(userId);
        if (user == null) {
            user = db.query(userId);
            CACHE.put(userId, user);
        }
        return user;
    }
}
2.2 场景 2:ThreadLocal 在线程池中泄漏
// 问题:ThreadLocal 未清理
public class ContextFilter implements Filter {
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) {
        CONTEXT.set(buildContext(req));
        chain.doFilter(req, resp);
        // 忘记 remove()!线程池线程复用,CONTEXT 一直持有
    }
}

// 修复:try-finally 确保清理
public class ContextFilter implements Filter {
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) {
        try {
            CONTEXT.set(buildContext(req));
            chain.doFilter(req, resp);
        } finally {
            CONTEXT.remove();  // 必须清理!
        }
    }
}
2.3 场景 3:监听器/回调未注销
// 问题:注册监听器但从未注销
public class OrderListener implements OrderEvent.Listener {
    public OrderListener(EventBus eventBus) {
        eventBus.register(this);
        // 但从未调用 eventBus.unregister(this)!
    }
}

// 修复:实现 AutoCloseable
public class OrderListener implements OrderEvent.Listener, AutoCloseable {
    private final EventBus eventBus;
    
    public OrderListener(EventBus eventBus) {
        this.eventBus = eventBus;
        eventBus.register(this);
    }
    
    @Override
    public void close() {
        eventBus.unregister(this);  // 注销监听器
    }
}

// 使用时:try-with-resources
try (OrderListener listener = new OrderListener(eventBus)) {
    // 监听事件
}
2.4 场景 4:IO 资源未关闭
// 问题:没有关闭流
public String readData(String url) {
    InputStream is = new URL(url).openStream();
    BufferedReader reader = new BufferedReader(new InputStreamReader(is));
    return reader.readLine();
}

// 修复:try-with-resources
public String readData(String url) {
    try (InputStream is = new URL(url).openStream();
         BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {
        return reader.readLine();
    }
}
2.5 场景 5:第三方 SDK 内部缓存泄漏
// 问题:初始化第三方 SDK,但从未销毁
public class BehaviorAnalysis {
    private static final BehaviorTracker TRACKER = new BehaviorTracker(config);
    
    public static void track(String event, Map<String, Object> properties) {
        TRACKER.send(event, properties);
    }
    // 但从未调用 TRACKER.destroy()!
}

// 修复:实现 AutoCloseable + @PreDestroy
@Component
public class BehaviorAnalysisService implements AutoCloseable {
    private final BehaviorTracker tracker;
    
    public BehaviorAnalysisService(Config config) {
        this.tracker = new BehaviorTracker(config);
    }
    
    @PreDestroy
    public void shutdown() {
        tracker.destroy();  // Spring 容器销毁时自动调用
    }
}

子问题 3:内存监控预警

3.1 多维度监控指标
@Component
public class MemoryMonitor {
    
    // 1. 堆内存使用率
    @Scheduled(fixedDelay = 60000)
    public void checkHeap() {
        MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
        MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
        double usedRatio = (double) heapUsage.getUsed() / heapUsage.getMax();
        
        if (usedRatio > 0.8) {
            alertService.warn("堆内存使用率过高: " + String.format("%.1f%%", usedRatio * 100));
        }
        metrics.gauge("jvm.heap.used.ratio", usedRatio);
    }
    
    // 2. Old 区增长趋势(核心!)
    @Scheduled(fixedDelay = 60000)
    public void checkOldGenTrend() {
        MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
        MemoryUsage oldUsage = memoryBean.getNonHeapMemoryUsage();
        
        oldGenHistory.add(new TimestampUsage(System.currentTimeMillis(), oldUsage.getUsed()));
        
        if (oldGenHistory.size() > 2) {
            long firstUsage = oldGenHistory.get(0).getUsage();
            long nowUsage = oldUsage.getUsed();
            double growthPerDay = (double) (nowUsage - firstUsage) / 
                ((System.currentTimeMillis() - oldGenHistory.get(0).getTime()) / (1000 * 3600 * 24));
            
            if (growthPerDay > 0.05 * oldUsage.getMax()) {
                alertService.error("Old 区增长过快: " + String.format("%.2f%%/天", growthPerDay * 100));
            }
            metrics.gauge("jvm.oldgen.growth.per.day", growthPerDay);
        }
    }
    
    // 3. GC 频率和 STW
    @Scheduled(fixedDelay = 60000)
    public void checkGC() {
        List<GarbageCollectorMXBean> gcBeans = ManagementFactory.getGarbageCollectorMXBeans();
        for (GarbageCollectorMXBean gc : gcBeans) {
            long count = gc.getCollectionCount();
            long time = gc.getCollectionTime();
            
            if (gc.getName().contains("Full GC") && count > 1) {
                alertService.warn("Full GC 频率过高: " + count + " 次");
            }
            if (time > 1000) {
                alertService.warn("GC STW 过长: " + time + "ms");
            }
        }
    }
}
3.2 告警阈值配置
指标警告阈值严重阈值告警动作
堆内存使用率> 80%> 90%警告 → 检查泄漏
Old 区增长> 5%/天> 10%/天警告 → 导出 heap dump
Full GC 频率> 1 次/小时> 5 次/小时警告 → 检查内存
STW 耗时> 1s> 3s警告 → 调 GC 参数

总结

子问题核心方案
1. 内存泄漏定位heap dump(2 次)→ MAT 分析(Dominator Tree + GC Root + Compare)→ 定位静态集合/监听器/类加载器
2. 5 个泄漏场景静态集合(Caffeine 修复)、ThreadLocal(finally remove)、监听器(AutoCloseable)、IO 资源(try-with-resources)、第三方 SDK(@PreDestroy)
3. 内存监控预警多维度监控(堆内存/Old 区增长/GC 频率/STW)+ 告警阈值 + 自动化处置

R3 架构设计 | 高阶

题目

场景:某电商系统缓存与数据库不一致问题频发,用户看到的价格与实际下单价格不同。商品详情页 QPS 5000,缓存命中率 95%,但价格不一致投诉率 0.5%,超卖 0.1%。

问题:

  1. 分析缓存与 DB 不一致的根因,给出 3 种典型场景。
  2. 设计缓存一致性方案,覆盖:读请求、写请求、异常场景。
  3. 高并发下如何防缓存穿透、缓存雪崩、热点 key?

原始回答

首先说一下不一致场景,可能性。首先是删除的过程中,有其他的线程访问,把缓存更新了。其次是在数据库应该是会存在分库分表、读写分离之类的。这个时候主从存在不一致,在这个不一致过程中,把从库的数据写到了缓存中,导致缓存不一致。还有可能就是缓存删除失败,缓存使用的是原本缓存。缓存一致性方案,现在当前的 TTL 时间是 30 分钟,觉得可以降低一些。然后在获取缓存数据的时候,可以增加强锁,再做一次读数据库,然后把主库的内容直接写到缓存中,被删除后。异常情况下的话,可以做,出现异常把那个日志记录到 MQ 中,MQ 中的特殊情况做特殊处理,通过定时任务,比如说它没有更新到,这时候通过 MQ 去进行更新对应的缓存,进行集中删除,或者是通过 binlog 日志,binlog 日志去对扫描所有变更时的缓存。然后一步地去把那个缓存清除,这样可以规避异常场景。穿透,缓存穿透的话,首先是截取,确保这个缓存如果是在数据内的话,就是不让,只让一个请求打到数据库上,而不是所有的请求都会穿到数据库上,这时候需要加一个锁,确保一次只有一个请求请能到数据库,然后把这个东西加载到内存中,然后后续的请求不让它再访问数据库。我缓存雪崩的核心问题就在于大量的缓存的 TTL 在同一个时间点,大家集中过期,这个时候可以做对 TTL 做加随机权重、加随机数,确保不会大家同时过期。热点 key 的话,看热点有多大。两种,要么就是分片缓存,要么就是做本地缓存、二级缓存,把热点 key 去进行一个本地的再缓存,防止大量的热点请求触发特殊情况。

点评

优点:

不足:


完整讲解

子问题 1:不一致根因分析

1.1 三种典型场景
场景时序不一致原因
并发读写T1: A 读缓存 miss → T2: B 更新 DB + 删缓存 → T3: A 回源 DB(读到旧值)→ T4: A 写缓存(旧值)A 的回源和 B 的删除并发,A 用旧值覆盖了缓存
主从延迟T1: B 更新主库 + 删缓存 → T2: A 读缓存 miss → T3: A 回源从库(主从延迟,读到旧值)→ T4: A 写缓存(旧值)从库数据延迟,回源读到旧数据
删除缓存失败T1: B 更新 DB 成功 → T2: B 删缓存失败 → T3: A 读缓存(旧值)缓存删除失败,A 读到旧缓存
1.2 根因总结
根因说明发生概率
删除缓存失败网络问题、Redis 宕机低(< 0.1%)
并发读写覆盖回源读到旧值,覆盖新缓存中(0.1-1%)
主从延迟回源从库读到旧数据中(取决于延迟)
TTL 过期 + 并发缓存过期时正好有更新低

子问题 2:缓存一致性方案

2.1 方案对比
方案一致性复杂度性能适用场景
Cache Aside(先更新 DB,再删缓存)最终一致低高通用场景
双删(更新 DB → 删缓存 → 延迟再删)最终一致中中高并发、强一致要求
Canal/binlog 监听最终一致高高大规模、解耦
读写锁 + 强一致强一致高低金融、支付

推荐:双删 + Canal 兜底

2.2 方案 A:双删策略
@Service
public class ProductService {
    
    @Autowired
    private ProductDao productDao;
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private Scheduler scheduler;
    
    @Transactional
    public void updatePrice(Long productId, BigDecimal newPrice) {
        String key = "product:" + productId;
        
        // 1. 第一次删除缓存(提前删除,减少不一致窗口)
        redisTemplate.delete(key);
        
        // 2. 更新 DB
        productDao.updatePrice(productId, newPrice);
        
        // 3. 延迟第二次删除(解决并发读写问题)
        //    延迟时间 > 读写事务耗时(通常 1-5 秒)
        scheduler.schedule(() -> {
            redisTemplate.delete(key);
            log.info("Second delete cache: {}", key);
        }, 3, TimeUnit.SECONDS);
    }
}

双删时序:

T1: 请求 B → 更新价格(¥99 → ¥89)
T2: B → 第一次删缓存
T3: B → 更新 DB
T4: 请求 A → 查缓存 → miss
T5: A → 回源 DB → 读到 ¥89(B 已更新)
T6: A → 写缓存(¥89)
T7: B → 延迟 3 秒 → 第二次删缓存
T8: 用户 C → 查缓存 → miss → 回源 DB → ¥89 → 正确!
2.3 方案 B:Canal 监听 binlog(推荐,解耦)
@RocketMQMessageListener(topic = "product_change", consumerGroup = "cache_group")
public class ProductCacheConsumer implements RocketMQListener<Message> {
    
    @Override
    public void onMessage(Message message) {
        BinlogEvent event = JSON.parseObject(message.getBody(), BinlogEvent.class);
        
        if (event.getTable().equals("product")) {
            String key = "product:" + event.getProductId();
            redisTemplate.delete(key);
            
            log.info("Delete cache by binlog: {}", key);
        }
    }
}

Canal 架构:

MySQL 主库 → binlog → Canal Server → RocketMQ → 缓存消费者 → Redis 删缓存
                                                    ↓
                                          订单消费者 → 业务处理

优点:

缺点:


子问题 3:缓存穿透、雪崩、热点 key

3.1 缓存穿透(查不存在的数据)

问题:请求查一个不存在的数据(如 productId=999999),缓存永远 miss,每次都打 DB。

方案 A:空值缓存

public Product getProduct(Long productId) {
    String key = "product:" + productId;
    Product product = redisTemplate.opsForValue().get(key);
    
    if (product != null) {
        return product;
    }
    
    // 缓存 miss,查 DB
    product = productDao.selectById(productId);
    
    if (product == null) {
        // 数据不存在,缓存空值(短 TTL)
        redisTemplate.opsForValue().set(key, new Product(), 60, TimeUnit.SECONDS);
        return null;
    }
    
    // 数据存在,正常缓存
    long ttl = 1800 + ThreadLocalRandom.current().nextLong(600);
    redisTemplate.opsForValue().set(key, product, ttl, TimeUnit.SECONDS);
    return product;
}

方案 B:布隆过滤器

public class ProductBloomFilter {
    
    // 预估 100 万商品,误判率 0.01%
    private final BloomFilter<CharSequence> bloomFilter = BloomFilter.create(
        new Funnel<CharSequence>() {
            @Override
            public int hashCode(CharSequence input) {
                return input.toString().hashCode();
            }
            @Override
            public void writeInto(HashCodeOutputStream out, CharSequence value) {
                out.writeBytes(value.toString().getBytes(StandardCharsets.UTF_8));
            }
        },
        1_000_000,
        0.0001
    );
    
    public void addProduct(Long productId) {
        bloomFilter.put(String.valueOf(productId));
    }
    
    public boolean mightContain(Long productId) {
        return bloomFilter.mightContain(String.valueOf(productId));
    }
}

// 查询时先过布隆过滤器
public Product getProduct(Long productId) {
    // 1. 布隆过滤器判断(不存在直接返回)
    if (!bloomFilter.mightContain(productId)) {
        return null;
    }
    
    // 2. 查缓存
    String key = "product:" + productId;
    Product product = redisTemplate.opsForValue().get(key);
    if (product != null) return product;
    
    // 3. 查 DB
    product = productDao.selectById(productId);
    if (product != null) {
        bloomFilter.addProduct(productId);
        redisTemplate.opsForValue().set(key, product, 1800, TimeUnit.SECONDS);
    }
    return product;
}

方案对比:

方案优点缺点适用场景
空值缓存简单、无额外依赖浪费内存(缓存空值)数据量小、穿透少
布隆过滤器不占缓存内存、拦截率高有误判率、需要维护数据量大、穿透多
3.2 缓存雪崩(大量 key 同时过期)

问题:1 万个商品缓存同时过期,瞬间打垮 DB。

方案:随机 TTL + 热点 key 永不过期

public void setCache(String key, Object value) {
    // 1. 基础 TTL + 随机偏移(防同时过期)
    long baseTtl = 1800;
    long randomTtl = ThreadLocalRandom.current().nextLong(600);
    long ttl = baseTtl + randomTtl;
    
    redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
}

// 2. 热点 key 永不过期(本地缓存兜底)
@Scheduled(fixedDelay = 3600000)
public void refreshHotKeys() {
    Set<String> hotKeys = getHotKeys();
    for (String key : hotKeys) {
        redisTemplate.expire(key, 2, TimeUnit.HOURS);
    }
}
3.3 热点 key(单 key 高并发)

问题:某个商品被 10 万 QPS 访问,Redis 单 key 压力过大。

方案:多级缓存(本地 + Redis)

@Service
public class ProductService {
    
    // L1: 本地缓存(5 秒过期)
    private final Cache<Long, Product> localCache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofSeconds(5))
        .build();
    
    // L2: Redis
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    public Product getProduct(Long productId) {
        // 1. L1 本地缓存
        Product product = localCache.getIfPresent(productId);
        if (product != null) {
            return product;
        }
        
        // 2. L2 Redis
        String key = "product:" + productId;
        product = redisTemplate.opsForValue().get(key);
        if (product != null) {
            localCache.put(productId, product);
            return product;
        }
        
        // 3. L3 DB
        product = productDao.selectById(productId);
        if (product != null) {
            redisTemplate.opsForValue().set(key, product, 1800, TimeUnit.SECONDS);
            localCache.put(productId, product);
        }
        return product;
    }
}

多级缓存架构:

L1: 本地缓存(Caffeine)→ 1ms,命中率 60%
L2: Redis → 5ms,命中率 30%
L3: DB → 50ms,命中率 10%

加权平均延迟 = 0.6×1 + 0.3×5 + 0.1×50 = 8.6ms

总结

子问题核心方案
1. 不一致根因并发读写覆盖、主从延迟、删除缓存失败
2. 一致性方案双删 + Canal 兜底(延迟 3 秒二次删除 + binlog 监听删缓存)
3. 穿透雪崩热点穿透:空值缓存/布隆过滤器;雪崩:随机 TTL + 热点续期;热点:多级缓存(本地 + Redis)

面试总结

各轮次表现汇总

轮次方向难度表现得分
R1数据库优化进阶冷热分离思路对,但分片键选择有严重缺陷70/100
R2性能优化进阶工具链方向正确,但定位流程太笼统75/100
R3架构设计高阶识别了不一致场景,但穿透/击穿混淆80/100

强项

  1. 冷热分离意识:不迁移全部数据,近期数据迁移、历史数据归档到 ES/冷存储
  2. 工具链认知:知道用 heap dump、MAT、Arthas 等工具定位问题
  3. 架构思维:知道用 Canal/binlog 解耦、多级缓存防热点

待提升点

  1. 分片键选择:需要理解"按时间分片用户查询命中所有分片"的问题,按 user_id 分片才是正确选择
  2. 内存泄漏定位流程:不是"用 Arthas 看热点图",而是 heap dump(2 次)+ MAT 分析(Dominator Tree + GC Root + Compare)
  3. 缓存穿透 vs 击穿:穿透是查不存在的数据(布隆过滤器/空值缓存),击穿是热点 key 过期(加锁)
  4. 方案完整性:回答问题时,确保覆盖所有子问题,特别是兜底方案
  5. 量化指标:需要给出具体阈值(如 Old 区增长 5%/天),不能只说"设立监控"

建议

  1. 数据库优化:深入学习分片键选择、双写迁移、数据验证方案
  2. 性能优化:掌握内存泄漏定位标准流程(heap dump + MAT 分析)
  3. 架构设计:区分缓存穿透/击穿/雪崩,掌握双删 + Canal 一致性方案
  4. 方案完整性:回答问题时,确保覆盖所有子问题,特别是兜底方案
  5. 量化指标:建立可量化的诊断指标,不能只说"需要补充"

会话全文记录

时间:2026-09-15

R1 数据库优化

用户回答:

首先第一个是分库分表意见。通过当前数据量跟数据表情况,订单表的话,正常查询的方式是以人、用户、时间维度。所以拆分的话,最好是以时间维度去作为分片键。用人和用用户维度的话,作为数分片键的话,查询很难通过一个单个查询去查询结果。而以人跟用商铺的查询,可以创建关联子表去冗余数据,然后去进行对应的查询跟统计。然后对于历史数据的迁移,首先 5 亿历史数据都有有价值吗;这 5 亿数据都有必要迁移吗?正常来说,历史大量的历史数据应该作为冷数据处理,放到冷存储里边,没有必要的查询。正常的话查询也会是统计性的查询,不会查 5 亿所有的历史数据,这些数据可以放到,比如 ES 啊,或者是做归档数据啊,这种,然后做历史查询,而不是去整体,这样我们可迁移的数据应该是最近 3 个月的,或者是最近一段时间有效期的,而不会全量的。非全量数据的话进行迁移,首先是把近期的已经确定状态的数据,因为订单本身是有一个订单自身状态的,订单状态不可变的数据首先可以进行直接的迁移移过去,这部分数据也不会有什么变动。其次的话还会有变动的这些数据可以增量的去迁移,把数据进行一点点的迁过去,同增量同步去做处理。迁移完后数据同步性。首先在迁移完成后,先保证两个库同步运行。然后对两个库在使用迁移工具做两个库的增量同步,然后进行数据扫描,确认两边的数据一致性。如果出现异常,不可能是回滚,是保证两个库是可以一直先使用主库,确认一下新库是否存在,存在哪些问题。回滚的话,所谓回滚应该就是使用原库,而不是说把数据回滚什么情况。这就是我的方案。

点评:

讲解重点:


R2 性能优化

用户回答:

内存泄漏定位的话,可以分析使用内存,加 JVM 内存参数,去在它的内存 GC,或者是出现问题 OM 的时候,进行输出服务器的内存镜像,然后把当前的内存情况具体去查看。另外可以使用 Arris 去看它的热点图,看当前对象的具体情况是什么样子的,哪些对象有巨大的热点,来去观察具体内存占用情况。还有 jmap 看当前占据的使用的内存的情况是什么样子的,IIS 里边也有可以看。 第二个内存泄露场景。首先可能是引用三方对象没有回收,引入对象没有回收。然后可能是 Timer、ThreadLocal 内存泄露,也可能是对象的 IO 没有关,IO 对象流没有关。可能线程池、ThreadLocal 的使用线程池了,然后没有没有被回收。具体可能是什么原因,可以根据实际的内存的镜像去查看,跟内存的那个内存的对象分布去观察,具体确认是什么原因导致的。内存监控预警的话,可以设立监控报警,服务器的自己监控报警,监控 ZLM 指标。然后当超过一定比例的时候,并且回收之后,回收差额出现多次回收回收不掉的时候,这时候就去发生告警信息,从而达到监控发生泄露的这么个情况。

点评:

讲解重点:


R3 架构设计

用户回答:

首先说一下不一致场景,可能性。首先是删除的过程中,有其他的线程访问,把缓存更新了。其次是在数据库应该是会存在分库分表、读写分离之类的。这个时候主从存在不一致,在这个不一致过程中,把从库的数据写到了缓存中,导致缓存不一致。还有可能就是缓存删除失败,缓存使用的是原本缓存。缓存一致性方案,现在当前的 TTL 时间是 30 分钟,觉得可以降低一些。然后在获取缓存数据的时候,可以增加强锁,再做一次读数据库,然后把主库的内容直接写到缓存中,被删除后。异常情况下的话,可以做,出现异常把那个日志记录到 MQ 中,MQ 中的特殊情况做特殊处理,通过定时任务,比如说它没有更新到,这时候通过 MQ 去进行更新对应的缓存,进行集中删除,或者是通过 binlog 日志,binlog 日志去对扫描所有变更时的缓存。然后一步地去把那个缓存清除,这样可以规避异常场景。穿透,缓存穿透的话,首先是截取,确保这个缓存如果是在数据内的话,就是不让,只让一个请求打到数据库上,而不是所有的请求都会穿到数据库上,这时候需要加一个锁,确保一次只有一个请求请能到数据库,然后把这个东西加载到内存中,然后后续的请求不让它再访问数据库。我缓存雪崩的核心问题就在于大量的缓存的 TTL 在同一个时间点,大家集中过期,这个时候可以做对 TTL 做加随机权重、加随机数,确保不会大家同时过期。热点 key 的话,看热点有多大。两种,要么就是分片缓存,要么就是做本地缓存、二级缓存,把热点 key 去进行一个本地的再缓存,防止大量的热点请求触发特殊情况。

点评:

讲解重点:


文档版本:v1.0

最后更新:2026-09-15

总题数:3 题

总字数:约 10,000 字