设计一个日数据量超500万的数据库结构
设计一个日数据量超500万的数据库结构
开场:需求边界确认(面试必做!)
面试官您好,在正式设计前,我先明确几个核心边界(避免答非所问):
- 假设场景:用户行为日志系统(互联网最常见的高吞吐场景)
- 核心指标:日增 500 万条 → 月增 1.5 亿 → 年增 18 亿
- 查询模式:90% 是「用户 ID + 时间范围」查询,少量全局统计
- 读写比例:1:3(写多读少,典型日志特征)
- 数据保留:6 个月热数据,超过归档到冷存储
- 一致性要求:最终一致即可,允许秒级延迟
核心架构选型 ✅
架构说明:
- Kafka 削峰填谷,应对突发流量
- MySQL 主从分离,读写分离
- Redis 缓存热点数据(最近 7 天)
- ES 支持复杂全文检索和统计
- 定时任务负责数据归档和过期删除
核心表结构设计 💡
3.1 基础表结构(user_behavior_log)
| 字段名 | 类型 | 长度 | 说明 | 索引 |
|---|---|---|---|---|
| id | bigint | 20 | 雪花算法主键 | PRIMARY KEY |
| user_id | bigint | 20 | 用户 ID | 联合索引 (user_id, event_time) |
| event_type | tinyint | 1 | 事件类型(1 = 点击,2 = 曝光,3 = 下单) | |
| event_time | datetime(3) | 事件时间(精确到毫秒) | 联合索引 (user_id, event_time) | |
| device_id | varchar | 32 | 设备 ID | |
| ext_info | json | 扩展字段(非结构化数据) | ||
| create_time | datetime | 入库时间 |
设计要点:
- 主键用雪花算法:避免分库分表后自增 ID 冲突,同时自带时间戳属性
- 字段最小化:event_type 用 tinyint 代替 varchar,节省存储空间
- JSON 存扩展字段:避免频繁改表,适配业务快速迭代
- 联合索引最左匹配:(user_id, event_time) 完美覆盖 90% 的查询场景
3.2 索引设计原则 ⚠️
- 只建必要索引:单表索引不超过 5 个,避免写入性能下降
- 避免冗余索引:如果有 (a,b) 联合索引,就不需要单独建 a 索引
- 定期清理无效索引:用
pt-index-usage工具分析索引使用情况
分库分表策略(核心中的核心!)
4.1 分库策略:按 user_id 哈希分库
- 分库数量:8 个库(2 的幂次,方便后续扩容)
- 路由规则:
库号 = user_id % 8 - 优势:数据均匀分布,避免单库热点,按用户 ID 查询性能最优
4.2 分表策略:时间 + user_id 哈希二级分表
- 分表周期:每月一张表(月增 1.5 亿,单表控制在 5000 万以内)
- 分表数量:每张月表再分4 张子表
- 路由规则:
表号 = (user_id % 4) + 月份偏移量 * 4 - 示例:2026 年 6 月的表 →
user_behavior_log_202606_0~user_behavior_log_202606_3
4.3 分库分表示意图
数据量计算验证:
- 日增 500 万 → 月增 1.5 亿
- 8 库 ×4 表 = 32 张表 / 月
- 单表月增:1.5 亿 / 32 ≈ 468 万条
- 6 个月热数据:单表≈2800 万条(远低于 MySQL 单表 5000 万的最佳实践上限)✅
性能优化关键点 🚀
- 批量写入:Kafka 消费后攒批 1000 条再批量插入 MySQL,写入性能提升 10 倍以上
- 读写分离:主库只写,从库负责所有查询请求,主库压力降低 70%
- 热点缓存:最近 7 天的活跃用户行为缓存到 Redis,缓存命中率≥80%
- 数据压缩:JSON 字段用 Snappy 压缩,存储空间节省 50% 以上
- 避免大事务:单条 SQL 操作不超过 1000 行,防止锁表和主从延迟
数据生命周期管理 📅
- 自动建表:每月 25 号提前创建下个月的分表,避免月底凌晨流量高峰时建表失败
- 自动归档:每月 1 号自动将 6 个月前的热数据归档到 HDFS 冷存储
- 自动删除:归档完成后删除 MySQL 中的过期表,释放存储空间
- 归档数据查询:通过 Presto 查询 HDFS 中的冷数据,满足历史数据分析需求
未来扩展方案 🔮
如果未来日增数据量突破 1000 万,可以按以下顺序升级:
- 分库扩容:从 8 个库扩展到 16 个库(2 倍扩容,无需数据迁移)
- 缩短分表周期:从每月一张表改成每两周一张表
- 引入时序数据库:将 MySQL 替换为 TDengine/InfluxDB,时序数据写入性能提升 10 倍以上
- 引入数据湖:用 Iceberg/Hudi 构建数据湖,支持更灵活的数据分析
核心代码实现(面试必写亮点💻)
1 分库分表路由算法(核心)
/**
* 分库分表路由工具类
* 技术亮点:位运算代替取模,性能提升3倍;支持2倍平滑扩容
*/
public class ShardingRouter {
// 分库数量(必须是2的幂次)
private static final int DB_COUNT = 8;
// 每个月的分表数量(必须是2的幂次)
private static final int TABLE_PER_MONTH = 4;
// 基准年份(用于计算月份偏移量)
private static final int BASE_YEAR = 2020;
/**
* 计算库号
*/
public static int getDbNo(long userId) {
// 位运算代替取模:a % 2^n = a & (2^n - 1)
return (int) (userId & (DB_COUNT - 1));
}
/**
* 计算表号
*/
public static int getTableNo(long userId, LocalDateTime eventTime) {
// 计算月份偏移量:(年-基准年)*12 + 月
int monthOffset = (eventTime.getYear() - BASE_YEAR) * 12 + eventTime.getMonthValue();
// 表号 = 月份偏移量 * 每月表数 + 用户哈希
return monthOffset * TABLE_PER_MONTH + (int) (userId & (TABLE_PER_MONTH - 1));
}
/**
* 获取实际表名
*/
public static String getTableName(long userId, LocalDateTime eventTime) {
int tableNo = getTableNo(userId, eventTime);
return String.format("user_behavior_log_%d", tableNo);
}
}2 雪花算法主键生成(解决时钟回拨)
/**
* 雪花算法ID生成器
* 技术亮点:解决时钟回拨问题,支持最大100ms的回拨容忍
*/
public class SnowflakeIdGenerator {
// 起始时间戳(2020-01-01)
private static final long START_TIMESTAMP = 1577808000000L;
// 机器ID位数
private static final long WORKER_ID_BITS = 5L;
// 序列号位数
private static final long SEQUENCE_BITS = 12L;
// 机器ID最大值
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
// 序列号最大值
private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS);
private final long workerId;
private long lastTimestamp = -1L;
private long sequence = 0L;
public SnowflakeIdGenerator(long workerId) {
if (workerId < 0 || workerId > MAX_WORKER_ID) {
throw new IllegalArgumentException("Worker ID超出范围");
}
this.workerId = workerId;
}
public synchronized long nextId() {
long currentTimestamp = System.currentTimeMillis();
// 处理时钟回拨
if (currentTimestamp < lastTimestamp) {
long offset = lastTimestamp - currentTimestamp;
// 回拨小于100ms,等待
if (offset <= 100) {
try {
wait(offset << 1);
currentTimestamp = System.currentTimeMillis();
if (currentTimestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨超过100ms,无法生成ID");
}
} catch (InterruptedException e) {
throw new RuntimeException("等待时钟恢复失败", e);
}
} else {
throw new RuntimeException("时钟回拨超过100ms,无法生成ID");
}
}
// 同一毫秒内,序列号自增
if (currentTimestamp == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQUENCE;
// 序列号用完,等待下一毫秒
if (sequence == 0) {
currentTimestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = currentTimestamp;
// 拼接ID
return ((currentTimestamp - START_TIMESTAMP) << (WORKER_ID_BITS + SEQUENCE_BITS))
| (workerId << SEQUENCE_BITS)
| sequence;
}
private long waitNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}3 Kafka 批量写入优化
/**
* Kafka消费者批量写入MySQL
* 技术亮点:攒批+超时双重触发,兼顾吞吐量和延迟;失败重试机制
*/
@Component
public class UserBehaviorConsumer {
@Autowired
private UserBehaviorMapper userBehaviorMapper;
// 批量大小
private static final int BATCH_SIZE = 1000;
// 最大等待时间(毫秒)
private static final long MAX_WAIT_TIME = 500L;
// 重试次数
private static final int MAX_RETRY = 3;
@KafkaListener(topics = "user_behavior_topic", groupId = "user_behavior_group")
public void consume(List<ConsumerRecord<String, String>> records) {
List<UserBehavior> batchList = new ArrayList<>(BATCH_SIZE);
long lastFlushTime = System.currentTimeMillis();
for (ConsumerRecord<String, String> record : records) {
try {
UserBehavior behavior = JSON.parseObject(record.value(), UserBehavior.class);
behavior.setId(IdGenerator.nextId());
batchList.add(behavior);
// 达到批量大小或超时,触发写入
if (batchList.size() >= BATCH_SIZE
|| System.currentTimeMillis() - lastFlushTime >= MAX_WAIT_TIME) {
batchInsertWithRetry(batchList);
batchList.clear();
lastFlushTime = System.currentTimeMillis();
}
} catch (Exception e) {
log.error("解析消息失败: {}", record.value(), e);
}
}
// 处理剩余数据
if (!batchList.isEmpty()) {
batchInsertWithRetry(batchList);
}
}
private void batchInsertWithRetry(List<UserBehavior> list) {
int retryCount = 0;
while (retryCount < MAX_RETRY) {
try {
// MyBatis-Plus批量插入
userBehaviorMapper.insertBatch(list);
return;
} catch (Exception e) {
retryCount++;
log.error("批量插入失败,重试第{}次", retryCount, e);
if (retryCount >= MAX_RETRY) {
// 写入失败,发送到死信队列
sendToDlq(list);
throw new RuntimeException("批量插入最终失败", e);
}
// 指数退避重试
try {
Thread.sleep(100L * (1 << retryCount));
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
}
}
}4 数据归档定时任务
/**
* 数据归档定时任务
* 技术亮点:分批次迁移,避免大事务;幂等性设计,支持重复执行
*/
@Component
@EnableScheduling
public class DataArchiveTask {
@Autowired
private UserBehaviorMapper userBehaviorMapper;
@Autowired
private HdfsService hdfsService;
// 每次迁移的批次大小
private static final int BATCH_SIZE = 10000;
// 数据保留时间(6个月)
private static final int RETENTION_MONTHS = 6;
// 每月1号凌晨2点执行
@Scheduled(cron = "0 0 2 1 * ?")
public void archiveExpiredData() {
LocalDateTime expireTime = LocalDateTime.now().minusMonths(RETENTION_MONTHS);
log.info("开始归档{}之前的数据", expireTime);
// 遍历所有需要归档的表
List<String> tablesToArchive = getTablesBefore(expireTime);
for (String tableName : tablesToArchive) {
// 幂等性检查:如果已经归档过,跳过
if (hdfsService.exists("/archive/" + tableName)) {
log.info("表{}已经归档,跳过", tableName);
continue;
}
// 分批次迁移数据
long lastId = 0L;
int count;
do {
List<UserBehavior> list = userBehaviorMapper.selectByTableAndId(tableName, lastId, BATCH_SIZE);
count = list.size();
if (count > 0) {
// 写入HDFS
hdfsService.write("/archive/" + tableName + "/" + lastId, list);
// 删除已迁移的数据
userBehaviorMapper.deleteByTableAndId(tableName, list.get(list.size() - 1).getId());
lastId = list.get(list.size() - 1).getId();
log.info("表{}已归档{}条数据", tableName, count);
}
} while (count == BATCH_SIZE);
// 归档完成,删除空表
userBehaviorMapper.dropTable(tableName);
log.info("表{}归档完成并删除", tableName);
}
log.info("数据归档任务完成");
}
}技术难点与解决方案(面试加分项✨)
| 技术难点 | 影响 | 解决方案 | 技术亮点 |
|---|---|---|---|
| ⚠️ 分库分表后跨库分页查询 | 性能极差,结果不准确,无法支持全局排序 | 1. 业务层强制限制:所有查询必须带 user_id 2. 全局统计查询走 Elasticsearch 3. 禁止无限制的跨库分页 | 从业务层面规避技术难题,用合适的工具做合适的事 |
| ⚠️ 热点数据问题(大 V 用户) | 单库单表压力过大,成为系统瓶颈 | 1. 热点用户单独分库(白名单机制) 2. 热点数据全量缓存到 Redis 3. 读写分离 + 从库负载均衡 | 热点隔离,避免一颗老鼠屎坏了一锅粥 |
| ⚠️ 数据倾斜 | 部分库表数据量远超其他,导致性能不均 | 1. 哈希取模前加盐(userId + 随机数) 2. 定期监控数据分布,动态调整路由 3. 引入一致性哈希算法 | 数据均匀分布,充分利用集群资源 |
| ⚠️ 分库分表扩容 | 传统扩容需要全量数据迁移,停机时间长 | 1. 采用 2 的幂次分库分表 2. 双写方案:先写新库,再同步旧数据 3. 灰度切流量,验证无误后下线旧库 | 零停机平滑扩容,对业务无感知 |
| ⚠️ 分布式事务 | 跨库操作无法保证原子性 | 1. 日志场景不需要强一致性,最终一致即可 2. 采用本地消息表 + 定时任务补偿 3. 必要时使用 Seata AT 模式 | 降低一致性要求,换取性能提升 |
| ⚠️ 主从延迟 | 写入后立即查询不到数据 | 1. 核心查询强制走主库(白名单) 2. 缓存更新采用先删后更模式 3. 监控主从延迟,超过阈值自动降级 | 平衡性能和一致性,避免业务异常 |
| ⚠️ 数据丢失风险 | Kafka 或 MySQL 宕机导致数据丢失 | 1. Kafka 开启 acks=all + 副本机制 2. MySQL 开启 binlog + 主从备份 3. 写入失败的数据发送到死信队列人工处理 | 多层数据保障,数据零丢失 |
真实面试模拟
真实面试模拟
面试官 👨💼:
好,聊个开放题。假设你正在设计一个物联网系统,每天要存入超过 500 万条 设备上报数据。业务上既要能查单设备的历史,又要能看全量趋势。你会怎么设计整体数据库架构?不用讲得太泛,抓住几个核心决策点。
候选人 🤔:
好的,我先分析一下量级。日均500万,写入平均每秒不到60条,压力主要在总量膨胀和查询上。
我的核心思路是:把写入、在线查询、分析查询拆开,数据按时间冷热分层。
整体架构用一张简图表示就是:
设备 → Kafka(削峰)→ 流计算(Flink) → 实时告警/聚合
→ 批量写入服务 → MySQL分库分表(热数据)
→ ClickHouse(温数据,分析)
→ OSS(冷数据归档)线上业务只碰热数据,分析全走 OLAP,互不干扰。
面试官 👍:
思路清晰。那聚焦到 MySQL 这块热数据,日增500万的表,你具体怎么建?注意索引和字段设计。
候选人 📝:
我追求字段精简、索引克制。假设存的是设备指标,建表大概这样:
CREATE TABLE device_data_20260604 (
id BIGINT UNSIGNED NOT NULL, -- 雪花ID,避免自增冲突
device_id VARCHAR(32) NOT NULL,
report_time DATETIME(3) NOT NULL, -- 毫秒精度
metric_code SMALLINT NOT NULL, -- 枚举,省空间
metric_value DECIMAL(12,4) NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_device_time (device_id, report_time), -- 核心联合索引
KEY idx_time_metric (report_time, metric_code)
) ENGINE=InnoDB
PARTITION BY RANGE (TO_DAYS(report_time)) (...); -- 按天分区关键点:
- 主键用雪花算法,分库分表时不会冲突。
- 只建了两个最必要的联合索引,覆盖查询,绝不多建,以免拖慢写入。
- 按天分区,方便30天后直接 truncate 冷数据。
面试官 💡:
索引上很克制。那分库分表策略呢?每天500万,一个月就1.5亿,单表肯定不行吧?
候选人 🎯:
对。我采用 两级切分:
- 库维度:按
device_id哈希分16个库,路由算法库序号 = hash(device_id) % 16。 - 表维度:每个库内再按天分表,表名
device_data_YYYYMMDD。
这样做的好处是:查单设备历史数据时,能直接定位到某一个库的某一张表,完全没有跨库事务和聚合的压力。每天定时清理旧表也只需要删分区或删表。
面试官 🔥:
好的。那写入链路怎么压榨性能?每秒虽不高,但你要预留增长空间。
候选人 ⚡:
我会上几个组合拳:
- 批量写入:从 Kafka 消费后攒批,每 1000 条或 1 秒刷一次,用
INSERT INTO ... VALUES (...),(...)...。 - 连接池隔离:16个分库每个一个独立的 HikariCP 连接池,避免争抢。
- 异步并行:线程池批量分发到不同分库,Future 异步等待结果。
- 精简 binlog:部分库可设为 ROW 格式且不做实时同步时,关掉不必要日志。
压测下来单库轻松到 8000+ TPS,16 库整体扛下 10 倍当前流量完全没问题 🚀。
面试官 🔍:
数据存好了,查询场景怎么办?比如“查某个设备最新一条数据”,“查某个设备过去7天曲线”,“查所有设备昨天每小时平均值”。
候选人 💬:
这三类正好对应不同方案,我分开处理:
| 查询场景 | 解决方案 |
|---|---|
| 单设备最新数据 | 每次上报异步写 Redis,用 ZSET 按时间排序,直接 O(1) 拿最新一条 |
| 单设备某时段历史 | 根据 device_id 路由到指定库表,走联合索引 idx_device_time,覆盖返回,毫秒级 |
| 全设备/全维度聚合 | 实时数仓那路 Flink 清洗后同步到 ClickHouse,聚合查询全走 OLAP,绝不拖 MySQL |
这样 TP 和 AP 彻底分离 ,MySQL 只做点查,分析引擎专做聚合。
面试官 🧐:
容灾和扩展性方面,怎么保证高可用?如果以后设备量翻10倍,怎么扩?
候选人 🛠️:
每个分库都配 主从 + 读写分离,从库承接报表类读请求,主库只写。
分库路由上,用 ShardingSphere-Proxy 或自研轻量SDK,动态感知节点。
扩容时,通过 一致性哈希 减少迁移量,或者用“双写新库 + 数据逐批迁移”方案,平滑过渡,基本不停服。
冷数据方面,超过30天的数据自动迁移到 Parquet 格式存 OSS,用 Presto/Trino 做离线分析,成本很低。
面试官 😊:
很不错,整个链路都覆盖到了。最后用几句话总结一下你的设计哲学吧。
候选人 🌟:
就三点:
- 库表不贪大,索引要吝啬——字段精简,只为关键查询建索引。
- 写入走缓冲,查询做分离——MySQL 做在线点查,OLAP 做聚合,谁也不碍谁。
- 时间分冷热,哈希拆孤表——按时间快速淘汰,按哈希均匀分摊压力。
这套架构可以伴随业务线性增长,每一步都有现成的中间件和开源方案支撑。
面试官 👨💼:
整体设计我很满意。这样,你再深入一下:挑几个你刚才提到的关键点,贴一下核心代码,让我看看你代码层面的实现功力。另外,你觉得这个场景下最大的技术难点是什么?怎么解的?
候选人 🧑💻:
没问题,我挑三个最体现技术深度的点来写伪代码,然后总结四个核心难点和我的解法。
🧬 核心代码片段(有技术亮点)
1. 高性能雪花 ID 生成(解决时钟回拨 + ShardingSphere 集成)
传统雪花算法怕时钟回拨,我内置了环形缓冲区预生成 + 回拨自旋等待:
// 雪花算法核心:workerId = 库索引,datacenterId = 机器IP hash
public class SnowflakeIdGenerator {
// 环形缓冲区,预先产生ID,减少锁竞争
private final RingBuffer<Long> ringBuffer;
public SnowflakeIdGenerator(int workerBits, int datacenterBits) {
this.ringBuffer = new RingBuffer<>(1024, this::nextIdInternal);
}
public long nextId() {
Long id = ringBuffer.poll(10, TimeUnit.MILLISECONDS);
if (id != null) return id;
// 兜底:时钟严重回拨,等待追赶,超过阈值抛异常
long currentMillis = System.currentTimeMillis();
if (currentMillis < lastTimestamp) {
long offset = lastTimestamp - currentMillis;
if (offset <= 5) {
LockSupport.parkNanos(offset * 1_000_000);
} else {
throw new RuntimeException("Clock moved backwards. Refusing to generate id");
}
}
return nextIdInternal();
}
// 原始雪花算法逻辑...
}亮点:RingBuffer 消除锁竞争,TPS 可达 1500万+/s,还能柔性应对最多 5ms 回拨。
2. 分库分表路由切面(自研轻量 SDK,无需 Proxy)
基于注解 + AOP,做到业务代码零侵入:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ShardingRoute {
String shardKey(); // 方法参数名
String tablePrefix(); // 表前缀
ShardStrategy strategy(); // 哈希或日期
}
@Aspect
@Component
public class ShardingAspect {
// 数据源持有器,存储当前线程选定的数据源和表名
private final ThreadLocal<ShardingContext> contextHolder = new ThreadLocal<>();
@Around("@annotation(route)")
public Object route(ProceedingJoinPoint pjp, ShardingRoute route) throws Throwable {
Object[] args = pjp.getArgs();
String shardValue = (String) args[getArgIndex(pjp, route.shardKey())];
// 一致性哈希确定库
int dbIndex = ConsistentHash.getDb(shardValue, 16);
// 今天日期确定表
String table = route.tablePrefix() + DateUtil.today();
DataSource dataSource = DataSourceManager.get(dbIndex);
try (ShardingContext ctx = ShardingContext.set(dataSource, table)) {
return pjp.proceed(); // SQL 中 ${tableName} 自动替换
}
}
}亮点:线程级数据源切换 + 注解声明式,切库逻辑只在切面,业务代码只关心业务。
3. 批量写入 + 多线程异步并行(最大榨取磁盘IO)
public class BatchWriter {
private final List<DeviceData> batchQueue = new ArrayList<>(1000);
@Scheduled(fixedDelay = 1000) // 每秒刷一次
public void flushAll() {
if (batchQueue.isEmpty()) return;
// 按分库分表分组
Map<ShardKey, List<DeviceData>> grouped = batchQueue.stream()
.collect(Collectors.groupingBy(this::computeShardKey));
List<CompletableFuture<Void>> futures = new ArrayList<>();
grouped.forEach((shard, list) -> {
futures.add(CompletableFuture.runAsync(() -> {
String table = "device_data_" + DateUtil.today();
jdbcTemplate.batchUpdate(
"INSERT INTO " + table + " (...) VALUES (...)",
list,
1000,
(ps, row) -> { /* 参数设置 */ }
);
}, writeExecutor)); // 独立线程池
});
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
batchQueue.clear();
}
}亮点:按分库分组并行写入,每个分库一个连接池,线程数 = 分库数,避免上下文切换开销。
4. 冷数据自动归档(分区交换 + OSS 上传)
MySQL 端使用分区交换,瞬间清理:
public void archiveDaily(Date date) {
String partitionName = "p" + DateUtil.format(date, "yyyyMMdd");
// 1. 交换到归档表(空表),瞬间完成,不锁业务读
jdbcTemplate.execute("ALTER TABLE device_data EXCHANGE PARTITION "
+ partitionName + " WITH TABLE device_data_archive");
// 2. 从归档表导出为 Parquet 压缩文件(Spark 或直接流式读取)
exportToParquet("device_data_archive", "oss://archive/" + partitionName + ".parquet");
// 3. 清空归档表
jdbcTemplate.execute("TRUNCATE TABLE device_data_archive");
}亮点:分区交换是 DDL 秒级操作,不影响线上查询;Parquet 列存节省 90% 存储成本。
🔥 技术难点与解决方案
我梳理了这个场景下四个棘手的点,以及我的解决方案:
| 难点 | 风险描述 | 解决方案 |
|---|---|---|
| 分布式主键全局唯一 | 分库分表后自增ID会冲突,UUID无序导致页分裂 | 🌟 雪花算法 + RingBuffer:高位时间戳保证趋势递增,环形缓冲提升性能,WorkerID 映射库索引,零碰撞。 |
| 热点设备写入倾斜 | 某些设备上报极频繁,哈希后单库压力大 | 🌟 一致性哈希 + 虚拟节点:避免简单取模导致的数据迁移雪崩,虚拟节点分散热点;写入端本地预聚合(每秒同设备合并一次)再落库。 |
| 冷热数据无缝切换 | 业务按时间查询时可能跨冷热边界,性能断崖 | 🌟 查询路由中间件:解析时间范围,≤30天走MySQL,>30天自动路由到ClickHouse,通过 UNION ALL 合并结果,应用层无感。 |
| 分库扩容数据迁移 | 从16库扩到32库时,历史数据重哈希迁移量大 | 🌟 双写+历史渐进迁移:新路由规则上线后,增量数据双写新老库;后台任务按设备ID区间逐批迁移历史数据,校验后切换读流量,整体不停服。 |
补充说明:热点设备写入倾斜的解法细节
如果用简单 hash % 16,某个设备爆量可能导致单库 CPU 飙升。我的防御手段是:
- 一致性哈希环,设备ID映射到虚拟节点(如每库150个虚拟节点),迁移时只影响相邻两个节点,压力更均匀。
- 网关层聚合:如果是边缘网关,每秒钟把同一个设备的多次上报打包成一个批量消息,写入次数从每秒100次降到1次。
- 服务端二级缓存:如果真的发现某个设备疯狂写入,可以在写入服务中对 device_id 加限流,超过阈值直接拒绝或采样写入。
面试官 😊:
代码功底很扎实,难点分析得也很透彻。特别是热点设备和冷热切换两个点,很多候选人会忽略。你这轮技术面我觉得没问题了,稍等我们安排下一轮。
