数据架构
这一课你将能做到
- 为提交记录表选择分片键并推导路由,说清为什么用 userId 而不是 problemId
- 区分复制、读写分离、分片三种手段各自解决的问题与触发时机
- 为排行榜实现「写规范化表 + 读预聚合」的 CQRS 简化版,并写出投影逻辑
为什么要学
流量靠加机器能解决,数据量靠加机器解决不了——单表 5 亿行、单库磁盘 2TB 时,再贵的机器也只是延后崩溃。数据架构回答的是:数据怎么拆、怎么放、怎么读,它是系统设计题里区分「背题」和「做过」的部分。
面试高频:「什么时候分库分表?」「分片键怎么选?」「分片后跨片查询怎么办?」「CQRS 是什么、什么时候用?」——本课全部用本站的提交表和排行榜落地。
人话:复制是多印几本一样的书分给不同人读(读扩展);分片是把一套百科全书按卷拆开放不同书架(写扩展+容量);CQRS 是写的时候按规矩记(规范化),读的时候提前把摘要做好(反规范化)——记账和看账用的是两套视图。
先建立图像
三种手段解决的问题完全不同:
| 手段 | 解决什么 | 触发信号 | 本站映射 |
|---|
| 主从复制 | 读扩展、灾备 | 读 QPS 超主库承受 | 题目浏览走从库 |
| 读写分离 | 读流量分流 | 读远大于写(9:1) | 列表/详情读从库 |
| 分片 Sharding | 写扩展、单机容量 | 单表过大、写 QPS 超限 | 提交表 10 亿行 |
先估算触发点:提交 50 万条/天,单表假设 5000 万行开始明显变慢 → 约 100 天就触顶。索引膨胀(每条 2KB + 索引 ~500B)、备份窗口拉长、DDL 锁表风险全部随之而来。分片不是「以后再说」,对本站是年度内必做。
分片键的取舍对比(面试核心):
按 user_id 分片(hash(userId) % 4):
✅ 「我的提交记录」一次路由命中 —— 最高频查询
✅ 写压力均匀(活跃用户分布均匀)
❌ 「某题目的全部提交」要扫 4 个分片 —— 但该需求是运营场景,低频可接受
按 problem_id 分片:
✅ 题目维度查询快
❌ 热门题目所在分片写压力倾斜(明星 key)
❌ C 端最高频的「我的记录」要扫全分片 —— 反了
结论一句话:分片键选「最高频查询的过滤条件 + 高基数 + 写入均匀」。高频 > 一切。
跟着做
步骤 1:提交表分片的 ShardingSphere-JDBC 配置
rules:
sharding:
tables:
submission:
actual-data-nodes: ds_${0..3}.submission # 4 库 × 1 表
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: user-mod
sharding-algorithms:
user-mod:
type: MOD
props:
sharding-count: 4
应用代码完全无感知——submissionMapper.selectByUser(userId) 自动路由到正确分片,这是分片中间件最大的价值:路由逻辑下沉,业务只管写 SQL。
步骤 2:跨片查询的两条出路
「某题目最近提交列表」要跨 4 个分片,直接聚合代价高:
出路 A 广播聚合:SQL 发到 4 片,各自 LIMIT 20,内存归并排序取 Top20
—— 中间件支持,适合低频运营查询
出路 B 索引表(异构读模型):写入时同步维护 submission_feed 表
(按 problem_id 组织,只存 feed 需要的字段,单表可再分片)
—— 空间换时间,适合高频公开列表页
本站题目页的「最新提交」标签访问量大,选 B;运营后台统计选 A。跨片不是禁区,是把高频跨片需求提前物化。
步骤 3:排行榜的 CQRS 简化版
-- 提交判定完成时写入(唯一事实来源)
INSERT INTO judge_result(submission_id, user_id, problem_id, passed, score, ...)
VALUES (...);
// Kafka 消费 judge-result 事件,更新投影(第 6 课的消费者顺带做)
@KafkaListener(topics = "judge-result")
public void project(JudgeResultEvent e) {
if (e.isPassed()) {
// ZSet 分数累加 —— 读模型只需支持「TopN」和「我的排名」两种查询
redis.opsForZSet().incrementScore("rank:weekly", e.getUserId(), e.getScore());
}
// 题目通过率:INCR pass:{problemId} —— 原子计数,避免每次 COUNT(*)
redis.opsForValue().increment("pass:" + e.getProblemId());
}
一致性保障(衔接第 3 课):投影是异步的,排行榜有秒级延迟——可接受;每天凌晨对账任务比对 SUM(score) 与 ZSet 总分,漂移则重建。
步骤 4:冷热分离与归档
分片之外的第二把刀。提交记录的访问规律:90% 的查询集中在最近 30 天(用户看自己的近期记录),历史记录只是偶尔翻。
-- 热表:近 90 天,分片,配齐索引
-- 冷表:历史归档,按月分表,只保留主键+摘要索引
INSERT INTO submission_archive_202510 SELECT ... FROM submission
WHERE create_time < '2025-07-01' AND create_time < '2025-08-01';
DELETE FROM submission WHERE create_time < '2025-07-01' LIMIT 5000; -- 分批删
效果:热表数据量砍掉 60%+,索引更瘦、buffer pool 命中率更高——比多分一片便宜得多,且常常被忘记做。
讲清楚要点
- 复制、读写分离、分片是三件事:复制解决可用性和读冗余,读写分离是把复制用于流量,分片解决写与容量。它们常组合出现(分片内主从),但解决的问题互不替代。
- 分片键的错误代价极高:选错后所有高频查询跨片,等于白分。选片三问:最高频查询带什么条件?基数够不够高?写入是否均匀?判题平台的答案都是 userId。
- 分布式 ID 必须提前设计:分库后自增主键会冲突。方案:雪花算法(Snowflake,趋势递增、机器号+时间戳)或号段模式(DB 批量取号)。提交 id 全局唯一是幂等和追踪的前提。
- CQRS = 写模型保正确 + 读模型保速度,用事件(Kafka)连接两者。它不是新数据库,是「承认读写形态不同、分别优化」的意识,本站排行榜就是最小实现。
- 先冷热分离,再考虑加分片:归档让表变小,很多「分片需求」其实消失。成本顺序:索引优化 < 冷热分离 < 读写分离 < 分片。
你可能会懵的地方
-
分库和分表什么关系?
分表解决单表索引树过深、锁竞争;分库解决单机磁盘/CPU/连接数上限。只分表不分库,数据还在一台机器上,容量天花板没动。本站先分库(4 实例),每库内 1 表即可,将来再细分表。
-
分片之后还能 JOIN 吗?
同片内(同 userId 的 user 和 submission)可以;跨片 JOIN 中间件支持有限且性能差。正确姿势是广播聚合(低频)或冗余字段/索引表(高频)——「反规范化」是分片世界的常态。
-
数据倾斜(某分片特别大)怎么办?
先确认倾斜原因:分片键不均匀(用 province 分片必然倾斜)→ 换键迁移;自然明星用户(一个工作室灌几千万提交)→ 单独给它切「热点分片」或限流。扩容翻倍(4→8 片)配合一致性哈希式迁移可控制在 1/4 数据量搬迁。
-
Event Sourcing 是什么,本站要用吗?
存「事件流」而非「当前状态」:不存「分数=350」,而存「AC(+100)、AC(+80)、TLE(0)」全序列。好处是可审计、可重放、天然知道历史;代价是查询要投影。本站只在「学习轨迹回放」子域有价值,全站 ES 是自我伤害。
-
什么时候直接换分布式数据库(TiDB 之类)?
当分片带来的跨片事务、迁移、运维成本 > 换库成本时。判据:频繁需要跨片强一致事务、团队无力维护 ShardingSphere。传统分库分表成熟稳定,TiDB 换来弹性但要接受不同性能模型——先算账再动。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 按 problem_id 分片提交表 | C 端高频「我的提交」全跨片 | 分片键跟最高频查询走(userId) |
| 用自增主键 + 分库 | 各库 id 冲突,幂等失效 | 雪花 ID / 号段模式 |
| 一上来分 64 片「一步到位」 | 运维复杂度爆炸,数据量根本用不满 | 从 4 片起步,留平滑扩容方案 |
| 每次读排行榜实时 COUNT/ORDER BY | 大表聚合拖死主库 | ZSet 预聚合 + 异步投影 + 对账 |
| 冷数据一直留在热表 | 索引膨胀,buffer pool 命中率下降 | 90 天归档,分批迁移删除 |
卡点急救
- 分片后部分接口变慢:先查是否意外跨片——SQL 里没带分片键会广播全部分片(看中间件 SQL 日志);补上路由条件,或为该查询建索引表。
- 分片扩容要迁移历史数据:双写方案——新分片规则生效后旧数据异步迁移(binlog 订阅或扫描任务),读先切「读新读旧兜底」,确认干净后下线旧路由。禁止停机全量搬迁。
- 排行榜数据与 MySQL 对不上:查投影消费者的重复/丢失(衔接第 6 课幂等),再跑对账任务重建 ZSet(
DEL rank:weekly 后从 judge_result 全量重算,几十万行秒级完成——读模型可重建正是 CQRS 的底气)。
面试常问
-
什么时候需要分库分表?怎么选分片键?
触发:单表行数使索引深度/DDL/备份成本失控(经验值千万级起步观察)、写 QPS 超单库极限、磁盘容量逼近。选片键三标准:最高频查询的过滤条件、高基数、写入均匀,并以本站提交表为例讲 userId vs problemId 的取舍。
-
分片后「全局排行榜」「跨片分页」怎么做?
两条路:禁止跨片实时聚合,改为读模型预聚合(CQRS,ZSet/ES);确需实时则各片取 TopN 内存归并(并发 4 路 LIMIT 20 → 归并 80 条取 20),并说明翻页深的代价。落点落在「高频查询必须物化」。
-
CQRS 是什么?代价是什么?
命令(写)与查询(读)用不同模型:写侧规范化保事务,读侧反规范化/预聚合保速度,事件驱动同步,读模型可随时重建。代价:最终一致(产品要接受延迟)、投影链路多一层要监控对账。举排行榜实例收尾。
自测三问
- 提交表分片为什么选 userId?如果运营强需求是「题目维度统计」怎么办?
- 复制、读写分离、分片分别解决什么问题?正确的实施顺序是什么?
- 排行榜的 CQRS 实现里,读模型坏了怎么恢复?为什么说「可重建」是读模型的关键性质?
小结一句话
数据架构在复制、读写分离、分片之间按「索引优化 < 冷热分离 < 读写分离 < 分片」的代价顺序演进;CQRS 的本质是把高频跨形态查询提前物化成可重建的读模型。
延伸