日增量500万的表结构如何设计

设计一个日数据量超500万的数据库结构

开场:需求边界确认(面试必做!)

在正式设计前,我先明确几个核心边界(避免答非所问):

  • 假设场景:用户行为日志系统(互联网最常见的高吞吐场景)
  • 核心指标:日增 500 万条 → 月增 1.5 亿 → 年增 18 亿
  • 查询模式:90% 是「用户 ID + 时间范围」查询,少量全局统计
  • 读写比例:1:3(写多读少,典型日志特征)
  • 数据保留:6 个月热数据,超过归档到冷存储
  • 一致性要求:最终一致即可,允许秒级延迟

核心架构选型

架构说明:

  • Kafka 削峰填谷,应对突发流量
  • MySQL 主从分离,读写分离
  • Redis 缓存热点数据(最近 7 天)
  • ES 支持复杂全文检索和统计
  • 定时任务负责数据归档和过期删除

整理了面试真题、每日技术知识点、系统学习路线,都汇总在个人网站 www.javadashen.com,有需要的同学自取。

公众号「Rain的Java大神之路」每天拆一个知识点,陪你悄悄变强,有空来坐坐。


核心表结构设计

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 入库时间

设计要点:

  1. 主键用雪花算法:避免分库分表后自增 ID 冲突,同时自带时间戳属性
  2. 字段最小化:event_type 用 tinyint 代替 varchar,节省存储空间
  3. JSON 存扩展字段:避免频繁改表,适配业务快速迭代
  4. 联合索引最左匹配:(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 万的最佳实践上限)

性能优化关键点

  1. 批量写入:Kafka 消费后攒批 1000 条再批量插入 MySQL,写入性能提升 10 倍以上
  2. 读写分离:主库只写,从库负责所有查询请求,主库压力降低 70%
  3. 热点缓存:最近 7 天的活跃用户行为缓存到 Redis,缓存命中率≥80%
  4. 数据压缩:JSON 字段用 Snappy 压缩,存储空间节省 50% 以上
  5. 避免大事务:单条 SQL 操作不超过 1000 行,防止锁表和主从延迟

数据生命周期管理

  • 自动建表:每月 25 号提前创建下个月的分表,避免月底凌晨流量高峰时建表失败
  • 自动归档:每月 1 号自动将 6 个月前的热数据归档到 HDFS 冷存储
  • 自动删除:归档完成后删除 MySQL 中的过期表,释放存储空间
  • 归档数据查询:通过 Presto 查询 HDFS 中的冷数据,满足历史数据分析需求

未来扩展方案

如果未来日增数据量突破 1000 万,可以按以下顺序升级:

  1. 分库扩容:从 8 个库扩展到 16 个库(2 倍扩容,无需数据迁移)
  2. 缩短分表周期:从每月一张表改成每两周一张表
  3. 引入时序数据库:将 MySQL 替换为 TDengine/InfluxDB,时序数据写入性能提升 10 倍以上
  4. 引入数据湖:用 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_id2. 全局统计查询走 Elasticsearch3. 禁止无限制的跨库分页 从业务层面规避技术难题,用合适的工具做合适的事
️ 热点数据问题(大 V 用户) 单库单表压力过大,成为系统瓶颈 1. 热点用户单独分库(白名单机制)2. 热点数据全量缓存到 Redis3. 读写分离 + 从库负载均衡 热点隔离,避免一颗老鼠屎坏了一锅粥
️ 数据倾斜 部分库表数据量远超其他,导致性能不均 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

这样做的好处是:查单设备历史数据时,能直接定位到某一个库的某一张表,完全没有跨库事务和聚合的压力。每天定时清理旧表也只需要删分区或删表。

面试官 :

好的。那写入链路怎么压榨性能?每秒虽不高,但你要预留增长空间。

候选人 :

我会上几个组合拳:

  1. 批量写入:从 Kafka 消费后攒批,每 1000 条或 1 秒刷一次,用 INSERT INTO ... VALUES (...),(...)...
  2. 连接池隔离:16个分库每个一个独立的 HikariCP 连接池,避免争抢。
  3. 异步并行:线程池批量分发到不同分库,Future 异步等待结果。
  4. 精简 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 飙升。我的防御手段是:

  1. 一致性哈希环,设备ID映射到虚拟节点(如每库150个虚拟节点),迁移时只影响相邻两个节点,压力更均匀。
  2. 网关层聚合:如果是边缘网关,每秒钟把同一个设备的多次上报打包成一个批量消息,写入次数从每秒100次降到1次。
  3. 服务端二级缓存:如果真的发现某个设备疯狂写入,可以在写入服务中对 device_id 加限流,超过阈值直接拒绝或采样写入。

面试官 :

代码功底很扎实,难点分析得也很透彻。特别是热点设备和冷热切换两个点,很多候选人会忽略。你这轮技术面我觉得没问题了,稍等我们安排下一轮。

【别走,交个朋友】

我是 Rain ,一个喜欢把复杂技术讲透、让代码落地的实践者。

如果你厌倦了四处收集碎片化的八股文,想看看一线项目里真实的架构决策和踩坑记录,欢迎来我的公众号「Rain的Java大神之路」坐坐。
在这里,我会把每一次技术复盘、每一个项目的设计源码,毫无保留地分享给你。

技术这条路很酷,我们结伴同行。
Keep Coding, Keep Loving —— 与所有 Java 同路人共勉。

原文链接:日增量500万的表结构如何设计-Rain的Java大神实战圈

© 版权声明
THE END
喜欢就支持一下吧
点赞12 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容