日期:2026-09-15
总题数:3 题(进阶 2 题 + 高阶 1 题)
方向覆盖:数据库优化、性能优化、架构设计
场景:某电商系统订单表 orders 单表 5 亿行,单库单表已无法支撑业务增长。查询 P99 延迟从 50ms 飙到 2s,DB CPU 长期 90%+,插入 TPS 3000/s 导致主从延迟 30s+。
id, user_id, order_no, amount, status, create_time, update_timeidx_user_id(user_id)、idx_create_time(create_time)、idx_status(status)问题:
首先第一个是分库分表意见。通过当前数据量跟数据表情况,订单表的话,正常查询的方式是以人、用户、时间维度。所以拆分的话,最好是以时间维度去作为分片键。用人和用用户维度的话,作为数分片键的话,查询很难通过一个单个查询去查询结果。而以人跟用商铺的查询,可以创建关联子表去冗余数据,然后去进行对应的查询跟统计。然后对于历史数据的迁移,首先 5 亿历史数据都有有价值吗;这 5 亿数据都有必要迁移吗?正常来说,历史大量的历史数据应该作为冷数据处理,放到冷存储里边,没有必要的查询。正常的话查询也会是统计性的查询,不会查 5 亿所有的历史数据,这些数据可以放到,比如 ES 啊,或者是做归档数据啊,这种,然后做历史查询,而不是去整体,这样我们可迁移的数据应该是最近 3 个月的,或者是最近一段时间有效期的,而不会全量的。非全量数据的话进行迁移,首先是把近期的已经确定状态的数据,因为订单本身是有一个订单自身状态的,订单状态不可变的数据首先可以进行直接的迁移移过去,这部分数据也不会有什么变动。其次的话还会有变动的这些数据可以增量的去迁移,把数据进行一点点的迁过去,同增量同步去做处理。迁移完后数据同步性。首先在迁移完成后,先保证两个库同步运行。然后对两个库在使用迁移工具做两个库的增量同步,然后进行数据扫描,确认两边的数据一致性。如果出现异常,不可能是回滚,是保证两个库是可以一直先使用主库,确认一下新库是否存在,存在哪些问题。回滚的话,所谓回滚应该就是使用原库,而不是说把数据回滚什么情况。这就是我的方案。
优点:
不足:
| 分片键 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 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. 路由到对应分片表查数据
# 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;
}
}
| 分片 | 数据量 | 说明 |
|---|---|---|
| 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 亿行 → 归档表(不分片)
阶段 1:准备(1 周)
- 部署分片中间件(ShardingSphere)
- 创建分片表 + 全局索引表
- 部署 Canal 监听 binlog
- 开发迁移工具 + 校验工具
阶段 2:双写(1-2 周)
- 应用层同时写旧表 + 新分片表
- Canal 监听 binlog 增量同步
- 灰度 1% 流量走新库
阶段 3:数据迁移(1 周)
- 全量迁移历史数据(按 user_id 范围分批)
- 增量同步实时追平
- 灰度 10% → 50% → 100%
阶段 4:切换(1 天)
- 100% 流量走新库
- 旧库保持只读(回滚兜底)
阶段 5:下线(1 周后)
- 确认无问题,下线旧库
@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;
}
}
@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;
}
}
}
// 验证 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);
}
}
回滚触发条件:
| 条件 | 阈值 | 动作 |
|---|---|---|
| 数据不一致 | 差异率 > 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 + 抽样校验,回滚靠反向同步保证旧库完整 |
场景:某 Java 服务上线一个月后堆内存从 20% 涨到 95%,Full GC 从 0 次/天涨到 20 次/天,STW 从 50ms 涨到 3s。运维每天凌晨 3 点手动重启一次才能恢复。
-Xmx8g,4 核 8G 容器问题:
内存泄漏定位的话,可以分析使用内存,加 JVM 内存参数,去在它的内存 GC,或者是出现问题 OM 的时候,进行输出服务器的内存镜像,然后把当前的内存情况具体去查看。另外可以使用 Arris 去看它的热点图,看当前对象的具体情况是什么样子的,哪些对象有巨大的热点,来去观察具体内存占用情况。还有 jmap 看当前占据的使用的内存的情况是什么样子的,IIS 里边也有可以看。 第二个内存泄露场景。首先可能是引用三方对象没有回收,引入对象没有回收。然后可能是 Timer、ThreadLocal 内存泄露,也可能是对象的 IO 没有关,IO 对象流没有关。可能线程池、ThreadLocal 的使用线程池了,然后没有没有被回收。具体可能是什么原因,可以根据实际的内存的镜像去查看,跟内存的那个内存的对象分布去观察,具体确认是什么原因导致的。内存监控预警的话,可以设立监控报警,服务器的自己监控报警,监控 ZLM 指标。然后当超过一定比例的时候,并且回收之后,回收差额出现多次回收回收不掉的时候,这时候就去发生告警信息,从而达到监控发生泄露的这么个情况。
优点:
不足:
Old 区从 30% 线性增长到 90%,每天增长 6%
↓
Full GC 20 次/天,STW 3s
↓
流量平稳,无业务高峰
↓
近期变更:新增"用户行为分析"模块 + 3 个第三方 SDK
↓
最大嫌疑:第三方 SDK 内部缓存/监听器/静态集合未释放
关键线索:内存线性增长(不是阶梯式),说明每次 GC 都回收不了,对象持续存活。
Step 1: 确认泄漏(看趋势)
↓
Step 2: 导出 heap dump(2 次,间隔 30 分钟)
↓
Step 3: MAT 分析(Dominator Tree + GC Root)
↓
Step 4: 定位泄漏对象(静态集合/监听器/类加载器)
↓
Step 5: 修复 + 验证
# 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. 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 |
// 问题:静态 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;
}
}
// 问题: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(); // 必须清理!
}
}
}
// 问题:注册监听器但从未注销
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)) {
// 监听事件
}
// 问题:没有关闭流
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();
}
}
// 问题:初始化第三方 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 容器销毁时自动调用
}
}
@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");
}
}
}
}
| 指标 | 警告阈值 | 严重阈值 | 告警动作 |
|---|---|---|---|
| 堆内存使用率 | > 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)+ 告警阈值 + 自动化处置 |
场景:某电商系统缓存与数据库不一致问题频发,用户看到的价格与实际下单价格不同。商品详情页 QPS 5000,缓存命中率 95%,但价格不一致投诉率 0.5%,超卖 0.1%。
问题:
首先说一下不一致场景,可能性。首先是删除的过程中,有其他的线程访问,把缓存更新了。其次是在数据库应该是会存在分库分表、读写分离之类的。这个时候主从存在不一致,在这个不一致过程中,把从库的数据写到了缓存中,导致缓存不一致。还有可能就是缓存删除失败,缓存使用的是原本缓存。缓存一致性方案,现在当前的 TTL 时间是 30 分钟,觉得可以降低一些。然后在获取缓存数据的时候,可以增加强锁,再做一次读数据库,然后把主库的内容直接写到缓存中,被删除后。异常情况下的话,可以做,出现异常把那个日志记录到 MQ 中,MQ 中的特殊情况做特殊处理,通过定时任务,比如说它没有更新到,这时候通过 MQ 去进行更新对应的缓存,进行集中删除,或者是通过 binlog 日志,binlog 日志去对扫描所有变更时的缓存。然后一步地去把那个缓存清除,这样可以规避异常场景。穿透,缓存穿透的话,首先是截取,确保这个缓存如果是在数据内的话,就是不让,只让一个请求打到数据库上,而不是所有的请求都会穿到数据库上,这时候需要加一个锁,确保一次只有一个请求请能到数据库,然后把这个东西加载到内存中,然后后续的请求不让它再访问数据库。我缓存雪崩的核心问题就在于大量的缓存的 TTL 在同一个时间点,大家集中过期,这个时候可以做对 TTL 做加随机权重、加随机数,确保不会大家同时过期。热点 key 的话,看热点有多大。两种,要么就是分片缓存,要么就是做本地缓存、二级缓存,把热点 key 去进行一个本地的再缓存,防止大量的热点请求触发特殊情况。
优点:
不足:
| 场景 | 时序 | 不一致原因 |
|---|---|---|
| 并发读写 | 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 读到旧缓存 |
| 根因 | 说明 | 发生概率 |
|---|---|---|
| 删除缓存失败 | 网络问题、Redis 宕机 | 低(< 0.1%) |
| 并发读写覆盖 | 回源读到旧值,覆盖新缓存 | 中(0.1-1%) |
| 主从延迟 | 回源从库读到旧数据 | 中(取决于延迟) |
| TTL 过期 + 并发 | 缓存过期时正好有更新 | 低 |
| 方案 | 一致性 | 复杂度 | 性能 | 适用场景 |
|---|---|---|---|---|
| Cache Aside(先更新 DB,再删缓存) | 最终一致 | 低 | 高 | 通用场景 |
| 双删(更新 DB → 删缓存 → 延迟再删) | 最终一致 | 中 | 中 | 高并发、强一致要求 |
| Canal/binlog 监听 | 最终一致 | 高 | 高 | 大规模、解耦 |
| 读写锁 + 强一致 | 强一致 | 高 | 低 | 金融、支付 |
推荐:双删 + Canal 兜底
@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 → 正确!
@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 删缓存
↓
订单消费者 → 业务处理
优点:
缺点:
问题:请求查一个不存在的数据(如 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;
}
方案对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 空值缓存 | 简单、无额外依赖 | 浪费内存(缓存空值) | 数据量小、穿透少 |
| 布隆过滤器 | 不占缓存内存、拦截率高 | 有误判率、需要维护 | 数据量大、穿透多 |
问题: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);
}
}
问题:某个商品被 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 |
用户回答:
首先第一个是分库分表意见。通过当前数据量跟数据表情况,订单表的话,正常查询的方式是以人、用户、时间维度。所以拆分的话,最好是以时间维度去作为分片键。用人和用用户维度的话,作为数分片键的话,查询很难通过一个单个查询去查询结果。而以人跟用商铺的查询,可以创建关联子表去冗余数据,然后去进行对应的查询跟统计。然后对于历史数据的迁移,首先 5 亿历史数据都有有价值吗;这 5 亿数据都有必要迁移吗?正常来说,历史大量的历史数据应该作为冷数据处理,放到冷存储里边,没有必要的查询。正常的话查询也会是统计性的查询,不会查 5 亿所有的历史数据,这些数据可以放到,比如 ES 啊,或者是做归档数据啊,这种,然后做历史查询,而不是去整体,这样我们可迁移的数据应该是最近 3 个月的,或者是最近一段时间有效期的,而不会全量的。非全量数据的话进行迁移,首先是把近期的已经确定状态的数据,因为订单本身是有一个订单自身状态的,订单状态不可变的数据首先可以进行直接的迁移移过去,这部分数据也不会有什么变动。其次的话还会有变动的这些数据可以增量的去迁移,把数据进行一点点的迁过去,同增量同步去做处理。迁移完后数据同步性。首先在迁移完成后,先保证两个库同步运行。然后对两个库在使用迁移工具做两个库的增量同步,然后进行数据扫描,确认两边的数据一致性。如果出现异常,不可能是回滚,是保证两个库是可以一直先使用主库,确认一下新库是否存在,存在哪些问题。回滚的话,所谓回滚应该就是使用原库,而不是说把数据回滚什么情况。这就是我的方案。
点评:
讲解重点:
用户回答:
内存泄漏定位的话,可以分析使用内存,加 JVM 内存参数,去在它的内存 GC,或者是出现问题 OM 的时候,进行输出服务器的内存镜像,然后把当前的内存情况具体去查看。另外可以使用 Arris 去看它的热点图,看当前对象的具体情况是什么样子的,哪些对象有巨大的热点,来去观察具体内存占用情况。还有 jmap 看当前占据的使用的内存的情况是什么样子的,IIS 里边也有可以看。 第二个内存泄露场景。首先可能是引用三方对象没有回收,引入对象没有回收。然后可能是 Timer、ThreadLocal 内存泄露,也可能是对象的 IO 没有关,IO 对象流没有关。可能线程池、ThreadLocal 的使用线程池了,然后没有没有被回收。具体可能是什么原因,可以根据实际的内存的镜像去查看,跟内存的那个内存的对象分布去观察,具体确认是什么原因导致的。内存监控预警的话,可以设立监控报警,服务器的自己监控报警,监控 ZLM 指标。然后当超过一定比例的时候,并且回收之后,回收差额出现多次回收回收不掉的时候,这时候就去发生告警信息,从而达到监控发生泄露的这么个情况。
点评:
讲解重点:
用户回答:
首先说一下不一致场景,可能性。首先是删除的过程中,有其他的线程访问,把缓存更新了。其次是在数据库应该是会存在分库分表、读写分离之类的。这个时候主从存在不一致,在这个不一致过程中,把从库的数据写到了缓存中,导致缓存不一致。还有可能就是缓存删除失败,缓存使用的是原本缓存。缓存一致性方案,现在当前的 TTL 时间是 30 分钟,觉得可以降低一些。然后在获取缓存数据的时候,可以增加强锁,再做一次读数据库,然后把主库的内容直接写到缓存中,被删除后。异常情况下的话,可以做,出现异常把那个日志记录到 MQ 中,MQ 中的特殊情况做特殊处理,通过定时任务,比如说它没有更新到,这时候通过 MQ 去进行更新对应的缓存,进行集中删除,或者是通过 binlog 日志,binlog 日志去对扫描所有变更时的缓存。然后一步地去把那个缓存清除,这样可以规避异常场景。穿透,缓存穿透的话,首先是截取,确保这个缓存如果是在数据内的话,就是不让,只让一个请求打到数据库上,而不是所有的请求都会穿到数据库上,这时候需要加一个锁,确保一次只有一个请求请能到数据库,然后把这个东西加载到内存中,然后后续的请求不让它再访问数据库。我缓存雪崩的核心问题就在于大量的缓存的 TTL 在同一个时间点,大家集中过期,这个时候可以做对 TTL 做加随机权重、加随机数,确保不会大家同时过期。热点 key 的话,看热点有多大。两种,要么就是分片缓存,要么就是做本地缓存、二级缓存,把热点 key 去进行一个本地的再缓存,防止大量的热点请求触发特殊情况。
点评:
讲解重点:
文档版本:v1.0
最后更新:2026-09-15
总题数:3 题
总字数:约 10,000 字