跳过正文

2026-06-10-数据密集型应用系统设计笔记

·38959 字·78 分钟
作者
菜狗
Focus
目录

第一章 可靠、可扩展、可维护
#

1. 可靠性 (Reliability)
#

目标:即使出现故障,系统仍能正确提供服务。

  • 硬件故障:磁盘坏道、电源烧毁、网卡挂掉。
    • 应对:冗余。数据库用主从(MySQL CHANGE MASTER TO),磁盘上RAID 10,电源双路,交换机堆叠。
  • 软件错误:某个服务内存泄漏导致 OOM,慢查询拖死整个库,级联失败。
    • 应对:资源隔离(Docker --memory=512m),快速回滚(kubectl rollout undo),断路器(Hystrix/Sentinel,circuitBreaker.errorThresholdPercentage: 50)。
  • 人为失误:误删表、配置写错。
    • 应对:最小权限(GRANT SELECT ON ...),变更审核,沙箱环境,数据库延迟从库(CHANGE MASTER TO MASTER_DELAY=3600)用于快速恢复误删。

具体操作级细节:

  • 监控四金信号:延迟、流量、错误、饱和度。Prometheus 配置示例:
    - record: job:request_latency_seconds:p99
      expr: histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, job))
    
  • 错误预算:SLO 允许 0.1% 错误,当月错误率已达 0.09%,应冻结上线,强制可靠性改进。
  • 混沌工程:用 chaosblade 模拟 MySQL 主库宕机:blade create mysql delay --time 3000 --port 3306,观察从库是否自动提升,应用是否重连。

2. 可扩展性 (Scalability)
#

目标:负载增加时,系统性能能按比例提升。

  • 描述负载:对于 web 服务,用“每秒请求数 (QPS)”;对数据库,“写入吞吐 (TPS) + 读写比例”;对缓存,“命中率”。举例:电商大促,预估 50 万 QPS 读商品详情,2 万 TPS 下单写。
  • 描述性能:必须用百分位。p99 比平均值重要得多。一条 PromQL 查 p95 延迟:
    histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))
  • 扩展策略
    • 垂直扩展:加 CPU 核、内存。瓶颈在数据库单表过大,升级 RDS 规格有限。
    • 水平扩展(无共享):加节点。无状态服务直接多副本 + 负载均衡(Nginx upstream / k8s Service)。有状态服务靠分片(按 user_id hash 分 64 库)。
  • 弹性伸缩:k8s HPA 配置:
    metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          averageUtilization: 70
    

排查扩展瓶颈具体做法:

  • top -H -p <pid> 看线程 CPU 占用;pprof 采样 Go 程序函数耗时。
  • 数据库慢查询日志:SET GLOBAL slow_query_log=1; SET long_query_time=0.05;EXPLAIN SELECT ... 看是否走索引。
  • 网络带宽:iftop -i eth0 观察流量峰值。

3. 可维护性 (Maintainability)
#

目标:让运维和后续开发者能高效工作。

  • 可运维性:统一日志格式(JSON,包含 trace_id),集中存储(ELK/Loki)。监控看板按“业务域”划分(用户、订单、支付),一键跳转日志。
  • 简单性:消除意外复杂性。用消息队列解耦,而不是让一个服务通过 RPC 调用 6 个其他服务。好的抽象:隐藏分布式事务细节,比如用 Seata AT 模式,代码只写 @GlobalTransactional
  • 可演化性:接口兼容。gRPC 只增加 optional 字段,废弃字段用 reserved。数据库加列:ALTER TABLE ... ADD COLUMN ... DEFAULT '' NOT NULL 不锁表(MySQL 8.0 即时加列)。

第1章面试题(含直接/原理/类似题,进阶回答)
#

面试题 1(直接关联)
#

题目:你们的服务出现 p99 延迟突然从 50ms 飙升到 2s,但 p50 和平均值几乎没变,如何排查?

常规回答:
长尾延迟通常由少量慢请求引起。排查:查看分布式链路追踪,找耗时长的 span;检查数据库慢查询,可能某个索引失效或突然大量全表扫描;检查 GC 日志,老年代 GC 停顿时长;检查网络重传率。

进阶回答(展现深度):

  1. 先定位是下游依赖还是自身计算导致。查 Jaeger/Zipkin 中 p99 请求的 trace,若 span 时间差集中在调用某个 RPC 或 DB 的“等待响应”上,则是下游慢。
  2. 如果是数据库:不是只看慢查询 SQL,还要用 SHOW ENGINE INNODB STATUS 查看锁等待信息,可能是 gap lock 导致的插入阻塞,结合快照隔离下的写倾斜场景排查事务持有锁未释放。
  3. 如果是自身计算pprof 抓 CPU profile,并对比 flame graph 在该时间段的变化。可能是正则表达式 ReDoS,或某个缓存未命中触发大量 JSON 反序列化。
  4. 深入网络层:用 ss -s 看 TCP 重传段,netstat -s | grep retrans。如果是跨 AZ 流量延迟抖动,可能需要调整客户端超时或使用区域感知路由。
  5. 兜底方案:临时将这个读服务的超时设为 1s,并打开断路器,牺牲部分用户体验保护整体,同时异步重试。

原理解析:长尾问题通常源于资源的争抢或排队,比如线程池满、连接池耗尽、CPU 缓存 miss 等。定位时要关联“排队论”,查看系统各个队列(线程池队列、网卡 backlog、数据库 innodb_thread_concurrency 等待)。


面试题 2(原理相关)
#

题目:为什么水平扩展(scale out)比垂直扩展(scale up)更受青睐?水平扩展引入了哪些根本性挑战?

常规回答:
水平扩展成本低(普通机器),无单点容量上限,弹性好。垂直扩展有物理极限,升级配置有停机时间。水平扩展的挑战是分布式一致性、网络延迟、数据分区等。

进阶回答(关联全书知识地图):

  1. 成本与弹性:用普通的 x86 服务器做无共享集群,可以按需增减,配合容器化实现秒级伸缩。垂直扩展需要“大机”,硬件成本指数上升,且故障影响面巨大。
  2. 水平扩展的根本挑战——部分失效(第 8 章)。单机模型中,要么工作要么崩溃,是确定的。水平扩展后,网络可能丢包、节点可能假死,系统必须在不确定的环境中做出确定响应。
  3. 引入的具体难题
  • 数据一致性:副本间如何保持同步(第 5 章),主从复制延迟导致读己之写异常。
  • 事务:跨节点事务需要 2PC 或 Saga(第 9 章),ACID 变成 BASE。
  • 数据分片:分区键选错会产生热点,rebalance 时数据迁移导致性能抖动(第 6 章)。
  • 时钟与顺序:事件排序不能靠墙上时钟,要向量时钟或逻辑时钟(第 8 章)。
  1. 选型结论:能垂直扩展先垂直,直到成本无法承受或遇到 I/O 带宽等物理极限,再拆分。状态服务拆分最痛苦,应优先拆分无状态服务。

面试题 3(类似场景题)
#

题目:如果让你为一个简单的用户积分系统设定 SLO,你会怎么定?当 SLO 可能达不到时,你会采取什么降级措施?

常规回答:
SLO:积分查询 p99 < 100ms,积分变动 p99 < 500ms,错误率 < 0.01%。降级:关闭积分兑换入口,只允许查询,拒绝增加积分写入。

进阶回答(融合可靠性与可扩展性权衡):

  1. SLO 设定
  • 积分查询(高频):p99 ≤ 50ms,错误率 ≤ 0.1%
  • 积分扣减(核心,但频率较低):p99 ≤ 200ms,错误率 ≤ 0.01%,且要求强一致性。
  • 定义好 SLI:Prometheus 中分别记录 integration_query_duration_secondsintegration_deduct_duration_seconds。注意区分读和写,因为写往往依赖锁,延迟天然更高。
  1. 降级策略设计(体现分层思想):
  • 一级降级(轻微抖动,如数据库只读副本延迟高):积分查询切换到读主库一小段时间,同时触发告警,等待副本追赶。
  • 二级降级(写入压力过大,MQ 积压):关闭“积分明细查询”和“积分抽奖”等非关键功能。积分扣减改为异步:用户操作后立刻返回“受理成功”,持久化到 Kafka,由后端慢慢消费更新积分,同时前端展示旧积分并提示“积分更新中”。
  • 三级降级(主库完全挂):启动积分服务只读模式,从备份只读副本读取可能滞后的积分,扣减类全部熔断,展示人工客服通道。此时接受最终一致性,违反 SLO 但保证不丢数据。
  1. 进阶思考:降级预案必须提前演练。用混沌工程在预发环境注入故障,验证降级开关能否在 30 秒内生效,以及恢复后数据补偿逻辑是否正确(幂等修复积分)。

原理串联:SLO 的定义直接决定了可靠性工作优先级。错误预算思想让团队在“可靠性”与“新功能上线”之间做量化权衡。此题综合了第1章的度量、第5章的复制延迟、第11章的异步消息等知识。

第二章 : 数据模型
#

1. 关系模型与文档模型的根本分歧
#

核心矛盾:数据局部性 vs 多对多关系表达。

  • 文档模型适合:数据是自包含的树状结构,通常一次读取整体,很少需要跨文档关联。
  • 关系模型适合:数据之间有很多多对多关联,需要灵活即席查询、连接(JOIN)和强一致性。

2. 阻抗不匹配 —— 具体案例与代码对比
#

场景:一个用户有多段工作经历和教育经历。

关系模型(PostgreSQL)
#

-- 需要多张表和外键
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL
);

CREATE TABLE jobs (
  id SERIAL PRIMARY KEY,
  user_id INT REFERENCES users(id),
  company TEXT,
  title TEXT,
  years INT
);

-- 查询用户及其所有工作经历:必须 JOIN
SELECT u.name, j.company, j.title, j.years
FROM users u
LEFT JOIN jobs j ON u.id = j.user_id
WHERE u.id = 1;

缺点:读的时候需要 JOIN,写的时候可能涉及多表。对象-关系映射(ORM) 复杂。

文档模型(MongoDB Shell)
#

// 用户文档直接嵌套工作经历
db.users.insertOne({
  _id: 1,
  name: "张三",
  jobs: [
    { company: "A公司", title: "工程师", years: 3 },
    { company: "B公司", title: "高级工程师", years: 2 }
  ]
});

// 一次查询就拿到全部数据,无 JOIN
db.users.findOne({ _id: 1 });

优点:读局部性好,一次磁盘 I/O 可能就取出完整用户数据。


3. 多对多关系 —— 文档模型的阿喀琉斯之踵
#

场景:学生和课程的多对多关系(一个学生选多门课,一门课有多个学生)。

如果用文档模型(MongoDB)初次尝试:
#

js

// 错误尝试1:在 student 文档里嵌套 course 列表
db.students.insertOne({
  _id: 1,
  name: "小明",
  courses: [
    { course_id: 101, title: "数据库" },
    { course_id: 102, title: "操作系统" }
  ]
});

问题:想查询“选了数据库课的所有学生”,需要扫描所有学生文档,或者对嵌套数组做不高效的多键索引。且如果课程信息变化(如改名),要更新所有包含该课程的学生文档,存在不一致风险。

文档模型正确的做法:引用(类似关系模型外键)
#

// 学生文档只存课程 id
db.students.insertOne({
  _id: 1,
  name: "小明",
  course_ids: [101, 102]
});
// 课程单独成集合
db.courses.insertMany([
  { _id: 101, title: "数据库" },
  { _id: 102, title: "操作系统" }
]);

// 查询:需要两次查询或使用 $lookup (类似 JOIN,性能较低)
db.students.aggregate([
  { $match: { _id: 1 } },
  { $lookup: {
      from: "courses",
      localField: "course_ids",
      foreignField: "_id",
      as: "courses"
  }}
]);

结论:此时文档模型已经失去了数据局部性优势,却付出了 JOIN 的性能代价。这种场景关系模型更自然。

关系模型处理多对多:
#

-- 关联表
CREATE TABLE enrollments (
  student_id INT REFERENCES students(id),
  course_id INT REFERENCES courses(id),
  PRIMARY KEY (student_id, course_id)
);
-- 查询小明选的课
SELECT c.title FROM courses c
JOIN enrollments e ON c.id = e.course_id
JOIN students s ON s.id = e.student_id
WHERE s.name = '小明';

关系模型对多对多关系的支持是天然的、高效的(索引连接)。


4. 图数据模型 —— 当关系压到一切
#

场景:社交网络“你可能认识的人”(二度好友),风控中的“资金循环检测”。

Cypher 查询示例(Neo4j):
#

cypher

// 查找用户1的好友的好友,排除自身和直接好友
MATCH (u:User {id: 1})-[:FRIEND]->(f:User)-[:FRIEND]->(fof:User)
WHERE fof <> u AND NOT (u)-[:FRIEND]->(fof)
RETURN fof.name, count(*) AS commonFriends
ORDER BY commonFriends DESC;

这种递归的、变长路径的查询,如果用关系型SQL需要写递归CTE,复杂且难以优化。图数据库在这种“边遍历”的查询模式上有数量级的优势。

关系型 SQL 的递归查询对比(PostgreSQL):
#

sql

WITH RECURSIVE friend_of_friend AS (
    SELECT f.friend_id FROM friends f WHERE f.user_id = 1
    UNION
    SELECT f2.friend_id FROM friend_of_friend fof
    JOIN friends f2 ON fof.friend_id = f2.user_id
)
SELECT * FROM friend_of_friend;
-- 但这很难限制深度,且不能直接表达“排除直接好友”

图模型的声明式使得深层图查询表达简洁,数据库内部可用索引无关邻接性高效遍历。


5. 声明式查询 vs 命令式查询
#

  • 声明式(SQL/Cypher):描述“想要什么”,数据库优化器决定执行计划(选哪个索引、用 hash join 还是 nested loop)。
  • 命令式(写代码循环):描述“怎么做”,比如先查所有用户,再 for 循环查每个用户的订单。这无法利用数据库的批量优化,且网络往返次数多。

具体例子:找过去一个月有订单的用户。

  • 命令式(MongoDB shell, 使用 forEach):
    let userIds = [];
    db.orders.find({ date: { $gte: oneMonthAgo } }).forEach(order => {
        userIds.push(order.user_id);
    });
    db.users.find({ _id: { $in: userIds } });
    
    缺点:需要把所有的 user_id 先拉到客户端内存,然后再发一次查询。而如果是声明式 JOIN,数据库可以内部优化。

声明式(SQL)

sql

SELECT DISTINCT u.name FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.order_date >= current_date - interval '1 month';

优化器可以选择用 orders 的索引找到符合条件的用户 ID,然后走 users 的聚簇索引取姓名,整个过程中没有将大量 ID 传到客户端。


6. 数据模型决策树(可直接套用)
#

  1. 数据间的关系主要是:
  • 一对一、一对多,且总是整体读取 → 文档模型(MongoDB)。
  • 多对多,且需要即席查询、强一致 → 关系模型(PostgreSQL)。
  • 关系本身是核心查询路径(多层、复杂图遍历) → 图数据库(Neo4j)。
  1. 如果团队对 SQL 熟练,且需要 ACID 事务,一律先考虑关系型,JSON 字段可作为灵活补充。
  2. 微服务中,每个服务有自己的数据库,可根据该服务的数据形状独立选择模型(多模型持久化)。

第2章面试题(含直接/原理/类似题,进阶回答)
#

面试题 1(直接关联)
#

题目:给出一个用户简历系统,有基本信息、多个工作经历、多个教育经历,你会用 MongoDB 还是 PostgreSQL?为什么?

常规回答:
用 MongoDB,因为简历是一个自包含的文档,一次读取就可以拿到所有信息,不需要 JOIN,性能好,而且工作经历等属于用户的一部分,嵌套很自然。

进阶回答(展现权衡与演化思维):

  1. 当前需求简单时:选择 MongoDB 正确。数据局部性好,对改善读延迟有明显效果。
  2. 业务演化陷阱:如果未来需要“查询所有在 A 公司工作过的用户”,或者“统计某大学毕业的学生人数”,这种基于嵌套数组的查询在 MongoDB 中会变复杂。虽然可以建多键索引,但索引效率、查询表达力都不如 SQL 的 JOIN + WHERE。
  3. 架构建议:针对这种可能有跨实体查询需求的情况,可以采用 CQRS 模式:写的时候用 MongoDB 存储用户聚合(主数据),同时通过 CDC 将数据同步到 Elasticsearch 或 PostgreSQL 副本,为分析查询服务。或者从开始就设计成关系型,利用 JSON 列(PostgreSQL JSONB)保留嵌套灵活性,这样既能整体读取(查询时取 JSONB),也能对子结构建立 GIN 索引。
  4. 选型总结:如果实体之间的关联将来可能被频繁独立查询,不要被初期简单读模型迷惑。选择关系型 + JSONB 混合,可以在“整体读”和“跨实体查询”之间取得平衡。
  5. mongodb适合什么场景呢?
  6. 设备数据模型频繁变化,需要灵活的schema。MongoDB 的解法:文档模型天然接受异构数据。每个机器人的事件或日志可以有不同的 JSON 字段,无需事先声明。
  7. 极高的写入吞吐,但查询模式单一
  8. 需要TTL
  9. 需要地理位置查询等
  10. 附加,我们对比下es,mongodb,cassandra,clickhouse等组件
  11. 简单对比
数据库第一印象核心定位一句话适用场景
MongoDB最像关系库的 NoSQL通用文档数据库写多变的、半结构化的业务数据,提供灵活查询。
Elasticsearch搜索王者分布式搜索引擎对任何非结构化或半结构化数据进行全文搜索和分析。
Cassandra高可用巨兽宽列存储数据库海量时序/事件数据,写入量极大,查询模式严格固定。
ClickHouse分析怪兽列式分析数据库对海量数据进行实时聚合、统计和报表计算。
  1. 复杂对比
对比维度MongoDBElasticsearchCassandraClickHouse
数据模型文档 (JSON)倒排索引 (JSON)宽列 (分区键+聚簇键)列式 (宽表)
最灵活。可嵌套数组/子文档,几乎无损映射 PG 的一行记录。扁平化。复杂嵌套需处理,天然适合文本,不适合复杂关联。需精心设计主键和聚簇键,模型设计依赖查询,类似关系库。宽表友好,需要做预关联或反范式化,不适合频繁跨表连接。
写入能力中高,单文档原子性操作。,近实时(默认1秒可见),重索引合并,不适合高频更新。极高,顺序追加写,设计上无单点瓶颈,非常适合海量写。极高,批量写入性能极佳,每次写入是一个数据分区,不建议高频小批量写。
CDC更新处理Upsert 很自然。CDC 消息可直接 updateOne$set,支持部分字段更新。隐式的删除+插入。更新一个字段,ES 也会重建整个文档,成本高。本质是带时间戳的 Upsert,需处理好乱序数据(LSN 做时间戳),用 LWW 策略。不擅长更新。更适合 CDC 消息作为追加事件流,用 ReplacingMergeTree 引擎后台去重。
查询能力通用查询最强。支持丰富的单文档查询、聚合管道、二级索引、简单的 JOIN。搜索和聚合最强。全文搜索、模糊匹配、复杂聚合(分桶、管道)、地理位置。严格受限。查询必须围绕主键设计,不支持随机查询和复杂 Join。OLAP 分析最强。SQL 支持好,聚合函数极其丰富,海量数据扫表极快。
适用查询模式点查、多条件组合查询,如订单详情、用户主页。全文检索、模糊过滤、实时大盘,如商品搜索、日志分析。仅按主键的点查或范围扫描,如查询某设备最近100条日志。大宽表的统计分析、报表,如计算某时段所有用户的留存率。
扩展性分片集群,对业务基本透明。分片+副本,运维复杂度较高。点对点分布式,线性扩展能力最强分片+分布式表,水平扩展简单。
一致性与可靠性可调一致性,多数写可保证强一致。近实时,极端情况可能丢少量数据。最终一致性,可调一致性级别(ONE/QUORUM/ALL)。默认最终一致,通过 insert_quorum 等可提高一致性。
运维复杂度。与关系库接近,文档模型直观。。需要精细的索引设计、内存管理、集群调优。中高。JVM 调优、分区键设计是核心,设计不当会热点。。硬件要求高(内存/SSD),但部署和扩缩容简单。

面试题 2(原理相关)
#

题目:什么是“阻抗不匹配”?在文档数据库中,它体现在哪些地方?

常规回答:
阻抗不匹配是指面向对象编程中的对象模型与数据库关系表模型之间的差异。文档模型可以存储嵌套对象,减少了 ORM 中的复杂映射。

进阶回答(结合存储细节和查询语言):

  1. 定义:阻抗不匹配是“应用层数据结构”与“数据库存储结构”之间的鸿沟。关系模型中,业务对象需要拆成多张表,通过外键关联,再通过 JOIN 组装回来,这就是匹配过程带来的心智负担和 ORM 性能问题。
  2. 文档模型缓解的方面:自包含的嵌套结构直接对应对象图,无拆解重组,读局部性好,这是对“一对一、一对多”关系的阻抗不匹配的良好解决。
  3. 文档模型新的不匹配点
  • 多对多关系:重新引入引用后,丧失了文档模型局部性的优点,而 JOIN 能力又弱于关系数据库,没有声明式优化器的支持,手写聚合管道复杂且难维护。
  • 查询语言:应用代码想要的是“我想找到所有上过数据库课的学生”,这是一个声明式意图,但在文档数据库如果不支持高效 JOIN,就只能用多次查询或低效聚合,这又是一个不匹配。
  • 数据完整性和约束:对象模型可能有复杂的业务规则(如一门课最多30人选),文档模型里实现这个约束需要复杂的多文档事务或应用层检查,而关系型一个外键 + 约束就能搞定。
  1. 结论:没有银弹。选择数据模型时,应基于数据间的关系复杂度访问模式,而不是简单看哪个映射容易。

面试题 3(类似场景题,结合多模型)
#

题目:设计一个电商系统,包含商品信息(名称、描述、属性列表、多规格 SKU)、用户订单(用户信息、商品列表、支付)、物流信息。你分别为商品、订单、物流选择什么数据模型?

常规回答:
商品信息用 MongoDB 文档存储,因为属性和规格嵌套;订单用关系型,因为涉及多表关联和强一致事务;物流信息可以用关系型或文档型。

进阶回答(结合分区分片、索引、事务和查询模式):

  1. 商品服务
  • 模型:文档模型(MongoDB 或 PostgreSQL JSONB)。商品属性(如手机的颜色、容量)可能每个类别都不同,无固定 schema,文档灵活性高。
  • 具体:product 集合包含嵌套数组 skus (每个 SKU 有 price, stock, attrs),支持一次读取商品详情页所有数据。
  • 索引:对 category_idskus.price 建立复合索引或 GIN 索引(PostgreSQL)以支持按品类筛选和价格排序。
  • 扩展:商品读远大于写,可以大量缓存 Redis,并监听 CDC 更新缓存。
  1. 订单服务
  • 模型:关系模型(PostgreSQL)。订单核心涉及强一致性:库存扣减、支付状态、用户钱包,需要 ACID 事务保证。
  • 具体:orders 表,order_items 表,payments 表,严格外键约束。使用 SERIALIZABLE 隔离级别或 SELECT FOR UPDATE 防止超卖写倾斜。
  • 扩展:按 user_id 哈希分片。订单历史归档到列存(ClickHouse)用于分析。
  1. 物流服务
  • 模型:可以混合。物流跟踪有多个状态更新(快照),适合文档模型或宽表。或者采用事件溯源:存储物流事件流(Kafka + Cassandra),当前状态用物化视图。
  • 具体:每个包裹一个文档,里面嵌套 statuses 数组(时间、地点、描述)。查询包裹状态一次返回全部轨迹。
  • 接口:物流状态可能被多个服务查询,可提供一个统一的物流查询 API,后端使用 Redis 缓存最新状态。
  • 物流服务 → MongoDB(事件嵌套或事件表)
    包裹轨迹按 ID 一次取出;用 event_id 唯一键 + 条件 $push 实现幂等,防止消息重复。
  1. 跨服务数据一致性
  • 订单支付成功后,需要通知物流系统发货。使用事务性发件箱模式:在订单数据库事务中同时写 outbox 表,然后 Debezium 捕捉变更发到 Kafka,物流服务消费。这保证最终一致性,而不是跨库 2PC。
  1. 决策原理:依据各业务域的数据形状和一致性需求独立选型(微服务+多模型持久化),并通过事件驱动避免强耦合。这体现了第2章数据模型和第5、9、11章的分布式的整合。
  2. 这里多提一句cassandra:Cassandra 是为 极高写入吞吐量、水平扩展、多数据中心就绪、可用性优先 而生的。它的设计取舍是:
  • :极致快(顺序写 + 无主复制,没有主库瓶颈)。
  • :只允许按主键高效读取。任何非主键查询都要慎重。
  • 关联:没有 JOIN。跨表/跨分区的一致性靠应用层处理。
  • 事务:单分区轻量事务(CAS)可用,跨分区不支持。 这意味着,Cassandra 不适合“需要复杂事务、频繁 JOIN、即席查询、模式频繁变化”的场景。

第三章:数据存储与检索
#

工作负载决定了存储引擎的选择,比方说事务型工作负载和分析型工作负载是两种不同的负载.

1. 存储引擎两大流派:日志结构 vs 就地更新
#

任何数据库都要解决两个根本问题:如何存数据到磁盘,如何快速找到它。索引加速读但拖慢写。两大流派由此分化:

  • 日志结构(Log-Structured):追加写入,不覆盖旧数据,靠后台压实回收空间。代表:LSM-Tree(RocksDB, Cassandra, HBase)。
  • 就地更新(Update-in-Place):覆盖原有位置,用页结构组织。代表:B-Tree(InnoDB, PostgreSQL)。

2. 从最简单的索引开始:哈希索引
#

原理:内存中维护一个哈希表,key 到文件字节偏移量的映射。写入时追加日志,并更新哈希表指向新偏移。读取时查哈希表,直接一次磁盘寻道读值。

具体实现细节

  • Bitcask (Riak 的存储引擎) 就采用这种方案。所有 key 必须在内存中。
  • 文件分段:日志写到一定大小(如 100MB)就切成新段 (segment),旧段合并压实 (compaction):一个 key 多次写,只保留最新值,丢弃旧段。
  • 崩溃恢复:重启时读所有段文件重建哈希索引,可通过快照索引文件加速。

致命限制:key 数量必须能全部放进内存。且范围查询效率极差(必须每个 key 单独查哈希)。


3. LSM-Tree 与 SSTable
#

SSTable (Sorted String Table):日志段内的 key 按顺序排列,且一个段内每个 key 只出现一次(合并后)。

优势

  • 合并段高效(类似归并排序)。
  • 查找某个 key 不需要在内存中维护所有 key 的索引,只需稀疏索引(每几个 key 记录一个偏移)。内存仅需存跳表或 B-Tree 等。
  • 范围查询只需找到起始 key,然后顺序读。

LSM-Tree 写入流程(以 RocksDB 为例)

  1. 写入先到内存中的 MemTable(通常是红黑树/跳表),同时记录 WAL (Write-Ahead Log) 用于崩溃恢复。
  2. MemTable 达到一定大小(如 64MB)后变为不可变 (immutable MemTable),并刷盘成一个 SSTable 文件。
  3. 后台线程执行 压实 (Compaction):读多个 SSTable,合并排序,丢弃过期值,生成新的 SSTable。

压实策略

  • Size-Tiered Compaction:等待同层有足够多大小相近的 SSTable,一次性合并成一个大文件。写放大低,但空间放大高,且读取可能需要查多个文件。
  • Leveled Compaction:数据分层,每层大小有上限,超过则压下下一层合并。写放大较高,但空间放大低,且读取只需检查少量文件(每层通常一个)。RocksDB 默认。

RocksDB 关键参数

  • write_buffer_size (MemTable 大小, 默认64MB)
  • max_write_buffer_number (最大 MemTable 数)
  • level0_file_num_compaction_trigger (L0 文件数达到此值触发压实)
  • max_bytes_for_level_base (L1 总大小上限)

4. B-Tree
#

结构:固定大小页(如 InnoDB 16KB),每个页包含有序键和子页指针。叶子页存实际数据(或数据指针)。查找从根开始二分定位到叶节点。

写入操作

  • 找到对应叶子页,若页有空闲空间就插入/更新。
  • 若页满,则分裂成两个半满页,并更新父节点的指针。可能递归向上分裂,增加树高。
  • 为了崩溃安全,B-Tree 通常带 WAL (redo log)。InnoDB 的 redo log 是循环写的,先写 redo log 再刷脏页。

B-Tree 调优(以 InnoDB 为例)

  • 填充因子 (fillfactor):页中初始保留多少空闲空间(如设为 70),为后续更新预留空间,减少页分裂。
  • 自增主键:插入总是发生在右侧,减少页分裂和随机 I/O。UUID 主键则插入无序,导致大量随机写和页分裂。
  • 覆盖索引:查询只走索引不回表,EXPLAIN 显示 Using index

5. LSM-Tree vs B-Tree 详细对比
#

维度LSM-TreeB-Tree
写入性能顺序写,写放大通常低,但压实可能造成写停顿 (write stall)随机写,可能多次 I/O(页分裂),写放大高
读性能可能需查多个 SSTable 和 MemTable,读放大高,但有布隆过滤器优化。压实影响读直接 B 树查找,路径短,读延迟稳定且可预测,缓存命中率高
空间放大Size-Tiered 高,Leveled 低页分裂可能导致页使用率一半,空间放大中等
压缩率可压缩,且可利用块压缩页级压缩,但页有空闲导致压缩率较低
典型产品RocksDB, Cassandra, HBase, LevelDBInnoDB (MySQL), PostgreSQL, SQL Server

当心写入停顿:LSM-Tree 的压实速度跟不上写入速度时,会阻塞写入防止文件数爆炸。监控 stall 时间。


6. OLAP 列式存储
#

思想:分析查询通常只需几列,列存可以将同列数据连续存放,压缩极高(枚举类型用字典编码+位图,数值用增量编码),且向量化处理利用 CPU SIMD 一次处理多个值。

Parquet 文件格式

  • 数据按行组 (row group) 划分(如 128MB),组内列存。
  • 每列有 min/max 统计,可谓词下推跳过不满足条件的块。
  • 支持嵌套数据(如 proto/JSON)通过 Dremel 编码。

ClickHouse 实战

  • 表引擎 MergeTree,必须指定 ORDER BY 作为稀疏索引键,PARTITION BY 按月分区。
  • 查询:SELECT os, count() FROM hits WHERE date = '2024-01-01' GROUP BY os。仅读取 date 和 os 两列。
  • 物化视图CREATE MATERIALIZED VIEW mv_hits_summary ENGINE = SummingMergeTree ... AS SELECT ... 预聚合。

OLTP 与 OLAP 隔离:线上库 MySQL 不能直接查大报表。Debezium CDC → Kafka → Flink/ClickHouse 同步。延迟亚秒级。


第3章面试题(含直接/原理/类似题,进阶回答)
#

面试题 1(直接关联)
#

题目:LSM-Tree 为什么写入比 B-Tree 快?什么情况下 LSM 的读性能会剧烈下降?

常规回答:
LSM 写入是顺序追加,比 B-Tree 随机写更快。读性能下降发生在大量 SSTable 文件堆积,压实跟不上写入时,因为查找需要合并多个文件。用布隆过滤器可以缓解。

进阶回答(深入到 OS 文件系统和压实细节):

  1. 写快的原因:不仅顺序写,还利用了磁盘的连续带宽,减少寻道和旋转延迟。B-Tree 更新一个页可能触发“读取-修改-写回”的随机 I/O,且随着数据变大,页分裂进一步增加写入成本。LSM 将随机写批量转为顺序写,对 SSD 也更友好(减少写放大和写入寿命损耗)。
  2. 读性能剧烈下降的场景
  • 无布隆过滤器的点查:LSM 需要从最旧的 SSTable 到 MemTable 逐层查找,复杂度与文件数成正比,可能产生数十次磁盘读。
  • 范围查询且压实严重落后:L0 文件 key 范围重叠,读取时要把所有重叠文件放入多路归并。如果 L0 文件达到上百个,每次范围查询都可能退化为归并排序,读放大几十倍。
  • 写入停顿的连锁反应:当压实跟不上时,写停顿会导致 MemTable 满,内存增不上去,读也会被阻塞等待压实释放空间。
  1. 缓解手段:选择 leveled compaction 控制读放大;动态调整 max_bytes_for_level_base 给 L1 足够空间吸收写入;使用 Bloom filter,布隆过滤器误报率设 bits_per_key=10,空间省但有效过滤绝大多数不存在的 key。

面试题 2(原理相关)
#

题目:B-Tree 的页分裂对性能有什么影响?如何从设计上减少页分裂?

常规回答:
页分裂导致额外的 I/O,可能使树变高,还会造成页碎片降低空间利用率。使用自增主键和合理填充因子可以减少页分裂。

进阶回答(结合索引设计和存储结构):

  1. 页分裂的代价
  • 数据被分成两页,原本在内存中连续的页现在可能分散,导致逻辑顺序和物理顺序不匹配(聚簇索引下的索引组织表,这破坏“聚簇性”,使范围扫描变成随机读)。
  • 分裂操作本身涉及将一半数据移动到新页,并更新父节点指针,这个过程可能递归触发上层分裂。在写入密集时,若同时有页分裂和 redo log 刷盘,可能导致写入延迟飙升。
  • 分裂后的页可能只有 50% 利用率,造成“内部碎片”。
  1. 减少页分裂的策略
  • 主键设计:使用单调递增主键(自增 id 或有序 UUID),保证插入总是在索引最右端。最右叶子页在分裂时,旧页满,新页为空,移动数据量较小,且不会分裂父节点(仅增加新页指针)。
  • 填充因子:建表时设 fillfactor=70 为后续更新预留页内空间,避免更新一条记录使页变满而分裂。适合经常更新且变长的列(如 varchar 字段后来变长)。
  • 分区表:按范围分区,写入压力分散到不同 B-Tree 结构,而不是全部集中到最右叶。
  • InnoDB 特定innodb_fill_factorCREATE TABLEALTER TABLE 时指定。并监控 Innodb_pages_writtenInnodb_buffer_pool_pages_dirty 观察写压力。
  1. 设计权衡:有时故意引入一点非顺序插入(如 UUID 用时间前缀)可以在避免热点和页分裂间取得平衡。

面试题 3(类似场景题,混合存储引擎)
#

题目:你需要设计一个系统的存储层:核心在线事务处理需要高并发随机读和强一致写,还要对历史数据做大量聚合分析查询。你会怎么组合使用存储引擎?具体架构?

常规回答:
OLTP 用关系型数据库(InnoDB),OLAP 用列式数据库(ClickHouse)。使用 ETL 或 CDC 工具同步数据。这样读写分离,互不影响。

进阶回答(给出具体组件和实时性权衡):

  1. OLTP 引擎:选 PostgreSQL (B-Tree),利用其 MVCC 支持快照隔离,SERIALIZABLE 可防写倾斜。主键用 UUID7(时间排序的 UUID)或自增 ID,配合填充因子 85。索引精心设计,只建真正需要加速的查询索引,避免索引膨胀。缓存热点数据于 Redis。
  2. 为什么选 PostgreSQL(或 MySQL InnoDB): - B-Tree 索引:支持随机读。根据主键或索引快速定位单行,延迟稳定在 1ms 以内(缓冲池命中)。 - MVCC + 快照隔离:读写互不阻塞。读不阻塞写,写不阻塞读,实现高并发。PG 的 READ COMMITTED 保证无脏读。 - 强一致写:通过 WAL (redo log) 保证崩溃恢复;通过同步复制或半同步实现副本一致;使用 SERIALIZABLE 隔离级别或 SELECT FOR UPDATE 防止写倾斜,保证业务不变。 - 连接管理:通过连接池(PgBouncer)支持数千并发连接,搭配预编译语句减少开销。 所以“高并发随机读”靠 B-Tree 高效定位,“强一致写”靠 WAL + 隔离级别保障。
  3. OLAP 引擎:ClickHouse 用 MergeTree 引擎,按小时/天分区,ORDER BY 用最常过滤的列组合,启用压缩 lz4
  4. 同步管道(CDC)
  • PostgreSQL 写事务时,Debezium 监控 WAL 日志,将变更转化为事件推送到 Kafka。
  • 下游 Flink 消费 Kafka,做数据清洗、维度宽表 join,并写入 ClickHouse。Flink 精确一次保证(checkpoint+ 幂等写出),延迟保持在秒级。
  1. 实时性权衡
  • 若需要亚秒级实时分析,可直接在 Flink 中做流聚合,结果写入 Redis 或直接展示,ClickHouse 作为后期修正和详细查询。
  • 批处理层(Spark SQL 夜间全量跑历史),可以修正流处理在异常时的错误。这形成 Lambda 架构,但代码统一用 Flink SQL 处理批和流。
  1. 架构细节的深度考虑
  • Schema 演化:OLTP 表加列,Debezium 会记录新的 schema 并传给 Kafka,Flink 消费者用 Avro 格式加 Schema Registry,可以向上兼容处理新老消息。
  • 数据删除:OLTP 删除行,Debezium 会捕获墓碑消息,Flink 可转成 ClickHouse 的 ALTER TABLE ... DELETE 或使用 ReplacingMergeTree 按版本去重。
  • 监控:管道延迟监控(Kafka consumer lag,Flink 事件延迟水印)。如果延迟超过 5 分钟报警,自动启动 Spark 批处理补充数据。
  1. 原理解析:这个设计充分利用了 B-Tree 的高性能随机读写和事务性,又利用 LSM/列存的批处理和分析能力,将存储引擎的“异构性”通过事件日志解耦,每个引擎做它最擅长的事。

第二部分:分布式数据系统
#

第五章:数据复制
#

  • 主从复制:所有的客户端写入操作都发送到某一个节点(主节点),由该节点负责将数据更改事件发送到其他副本(从节点)。每个副本都可以接收读请求,但内容可能是过期值。
  • 多主节点复制:系统存在多个主节点,每个都可以接收写请求,客户端将写请求发送到其中的一个主节点上,由该主节点负责将数据更改事件同步到其他主节点和自己的从节点。
  • 无主节点复制:客户端将写请求发送到多个节点上,读取时从多个节点上并行读取,以此检测和纠正某些过期数据。

每种方也都有其优点和缺点。主从复制非常流行,主要是因为它很容易理解,也不需要担心冲突问题。而万 一 出现节点失效、网络中断或者延迟抖动等情况,多主节点和无主节点复制方案会更加可靠,不过背后的代价则是系统的复杂性和弱 一致性保证

  • 写后读 一致性:保证用户总能看到自己所提交的最新数据。
  • 单调读:用户 在某个时间 点读到数据之后,保证此后不会出现比该时间点更早的数据 。
  • 前缀一致读:保证数据之间的因果关系,例如,总是以正确的顺序先读取问题,然后看到回答。

1. 复制的目的
#

  • 高可用:主库挂了从库顶上。
  • 读扩展:分散读取到多个从库。
  • 低延迟:把数据放在用户附近(多地域)。
  • 复制的核心难题:如何处理写入。所有复制方案的分歧都由此产生。

2. 主从复制 (Leader-Follower)
#

运行机制

  • 写入只走主库 (leader),从库 (follower) 拉取主库的日志,按顺序重放。
  • 复制可以是同步异步,或半同步

同步 vs 异步

  • 同步:主库等所有从库确认写入才返回。保证数据不丢(RPO=0),但任何一个从库卡住都会阻塞写。
  • 异步:主库写成功就返回,从库后台追。延迟低,但主库故障可能丢失已提交数据。
  • 半同步:只等一个从库同步,其他异步,折中。

具体配置示例 (MySQL 8.0)

sql

-- 从库配置
CHANGE MASTER TO
  MASTER_HOST='10.0.1.1',
  MASTER_USER='repl',
  MASTER_PASSWORD='secret',
  MASTER_LOG_FILE='mysql-bin.000001',
  MASTER_LOG_POS=156;
START SLAVE;

-- 检查延迟
SHOW SLAVE STATUS\G  -- Seconds_Behind_Master

启用半同步插件:

sql

INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
SET GLOBAL rpl_semi_sync_master_enabled = 1;
SET GLOBAL rpl_semi_sync_master_wait_for_slave_count = 1;

新增从库:通过快照 (mysqldump --single-transaction) 或 Percona XtraBackup 热备份全量数据,记录备份时刻的 binlog 位置,恢复后从该位置开始同步。

故障切换 (Failover)

  • 手动:通过 SHOW SLAVE STATUS 判断哪个从库最接近主库(看 Master_Log_File / Exec_Master_Log_Pos),提升为新主,其他从库 CHANGE MASTER 到新主。
  • 自动:Orchestrator 探测主库故障,自动选择最先进的从库提升。

3. 复制延迟问题 (Replication Lag) 及处理
#

读写分离后,从库异步复制会引入最终一致性。应用程序可能观测到以下异常,必须针对性解决:

异常1:读己之写 (Read-Your-Writes)

  • 场景:用户发帖后刷新页面看不到自己的帖子。
  • 解决方案:
    • 方案A:写后的一段时间(如10秒)强制读主库。在会话中设置标记。
    • 方案B:客户端记住最后写入时间戳或版本号,读请求带上,从库必须追到该位置后才返回结果(需要数据库支持,如 MongoDB 的 afterClusterTime)。
    • 方案C:将同一用户的读写都路由到同一个主库的分区或主从组(粘性会话)。

异常2:单调读 (Monotonic Reads)

  • 场景:用户第一次刷新看到帖子,第二次刷新帖子消失(因为两次读落到了不同延迟的从库)。
  • 解决:基于用户ID哈希将用户的读请求固定到同一从库。

异常3:前缀一致读 (Consistent Prefix Reads)

  • 场景:观察者先看到“回复”,后看到“原帖”(因果乱序)。
  • 解决:确保有因果关系的写入都写入同一个分区(如果使用分片),或使用事务/因果一致性协议。在复制环境中较难保证,通常需要应用层因果序列。

4. 多主复制 (Multi-Leader)
#

适用场景

  • 多活数据中心:每个数据中心一个主库,就近写入,冲突异步解决。
  • 离线协同编辑(如 Google Docs 通过 OT/CRDT)。

冲突检测与解决

  • 冲突:两个数据中心同时修改了同一行。
  • 检测:可在复制时检测冲突,也可延迟检测。
  • 解决方案:
    • 最后写入胜利 (LWW):靠时间戳。因分布式时钟不可靠,会静默丢数据,只适用于可丢弃数据。
    • 应用自定义合并:实现冲突处理函数。
    • CRDT (Conflict-free Replicated Data Types):数据结构保证合并自动收敛,无需外部冲突解决。如计数器 INCR、集合 Add-Wins Set

5. 无主复制 (Leaderless Replication)
#

Dynamo 风格:客户端向多个副本直接读写,没有主库角色。

仲裁协议 (Quorum)

  • 副本数 N,写确认数 W,读确认数 R
  • 只要 W + R > N,读一定能包含最新写(因为至少一个副本重叠)。
  • 典型配置:N=3, W=2, R=2,能容忍1个节点故障。

Cassandra 示例

sql

CONSISTENCY QUORUM;
SELECT * FROM users WHERE user_id = 123;

读修复 (Read Repair):读时比对所有副本,若有滞后副本,发更新修复。

反熵 (Anti-Entropy):后台 Merkle 树比较,修复不一致。

Hinted Handoff:写入时目标节点宕机,协调者写一个 hint 到另一节点,目标恢复后回传。


第5章面试题(3道)
#

面试题1(直接关联)
#

题目:读写分离的主从架构中,用户提交订单后刷新页面可能看到空订单列表,解释原因并给出解决方案。

常规回答:
因为写主库,读从库,从库复制延迟导致读不到刚刚的写入。解决方案是“写后读主”,在写操作后短时间内将读路由到主库。

进阶回答(结合多种方案及权衡):

  1. 根本原因:主库 binlog 产生到从库 relay log 应用存在延迟(Seconds_Behind_Master > 0),异步复制下此延迟无法消除。
  2. 方案对比
  • 方案A (立即读主):最简单。用户写后立即返回的页面或 API,在接下来30秒内的查询全打主库。缺点是主库压力增加,且需要会话状态传递这个意图。
  • 方案B (客户端版本号):记录提交订单时返回的 versiontimestamp,后续查询带上这个 version,从库必须等到自己同步到此版本才返回。MongoDB 支持 afterClusterTime,但 MySQL 需要应用额外写心跳表实现。
  • 方案C (改写逻辑):对于订单列表这类场景,提交订单后直接在应用内存或 Redis 中暂存订单详情,页面优先显示缓存,再与从库数据合并。
  1. 监控先行:必须监控 Seconds_Behind_Master,设定阈值告警。若延迟持续大于2秒,应临时关闭读从库,全量读主,放弃扩展性保体验。
  2. 最终答案:通常采用方案A + 方案C 组合,再加上从库延迟监控,做动态切换。

面试题2(原理相关)
#

题目:为什么“最后写入胜利” (LWW) 的冲突解决方式在多主复制中是“危险”的?适合什么样的场景?

常规回答:
LWW 依赖时钟,分布式系统时钟无法完全同步,可能导致旧数据覆盖新数据。仅适用于可容忍数据丢失的非关键场景如缓存。

进阶回答(结合 Google TrueTime 和 CRDT):

  1. 危险的根源:LWW 判断先后只靠物理时间戳。如果写入并发,或网络延迟/GC 导致早发生的事件时间戳更晚,晚发生的事件会被丢弃,产生静默数据丢失。
  2. 理论解释:根据分布式系统理论,仅靠物理时间戳无法建立全序,只能捕获可能因果,会丢失并发写入的一方。违背了并发语义。
  3. Google Spanner 的解决方法:使用 TrueTime API 给时间戳加上误差范围,通过 commit wait 等待误差过去,保证物理时间能反映真实事务顺序。这就是给 LWW 加上了安全保证,但普通系统没有 TrueTime。
  4. 适用场景:只用于数据可丢弃的“软状态”,如:用户的最后登录时间、浏览器偏好设置、缓存条目。即使偶尔丢一次写入,后果不严重。
  5. 更好的替代:对于必须保留并发写入的值,使用 CRDT 或显式的应用层合并。CRDT 的计数器、集合可保证合并收敛且不依赖时间戳。例如用 ORSet 存用户点赞列表,合并自动去重+增加。

面试题3(类似场景题)
#

题目:如何设计一个能容忍整个区域故障的多活数据库架构?

常规回答:
采用多主复制,每个区域一个主库,相互复制。使用异步复制和冲突解决策略(如 LWW 或应用冲突处理)。客户端就近写入,数据最终一致。

进阶回答(考虑共识、分区策略和可运维性):

  1. 架构选型
  • 多主复制:简单,但冲突解决复杂,运维负担重,仅适用于吞吐极高且冲突率明确可控的业务。
  • 共识协议跨区域:使用基于 Raft 的分布式数据库(如 CockroachDB, TiDB, YugabyteDB)将集群跨多个区域部署。通过修改 Raft 组成员的地理分布,让每个区域有一个 leader,利用 leaseholder 就近提供读写,内部 Raft 自动同步,对外透明。这是当前工程上的较优解
  • 无主+跨区域路由:Cassandra 的 NetworkTopologyStrategy,每个 DC 有多副本,写 LOCAL_QUORUM 限制在本地 DC,同时异步复制到远端。提供低延迟但最终一致性。
  1. 关键点
  • 冲突处理:不能简单用 LWW,需用 CRDT 或自定义合并(如购物车,多区域同时加商品用 add-wins set)。否则数据可能丢失。
  • 写延迟:跨区域同步会导致写延迟高(光速限制)。如果业务必须强一致性,写延迟至少是区域间 RTT。可降级为本地写+最终一致。
  • 脑裂防护:网络分区时,多主系统两边都能写入,会出现绝对不一致。要设计精细的冲突解决逻辑或使用共识锁住一侧。
  1. 运维复杂:多活拓扑的故障恢复极其复杂,必须有完善的监控和自动修复工具。定期对冲突进行审计。
  2. 核心结论:除非绝对需要区域级别可用性,否则不要多活。更简单的是“单区域强一致+跨区域异步备份”。多活带来难以量化的冲突风险。

第六章:数据分区
#

1. 分区的目的
#

  • 当数据量大到单机放不下,或写入量大到单节点 I/O 扛不住,必须切分数据到多节点。
  • 分区与复制正交:每个分区可以有多个副本(第5章)。

2. 分区策略
#

按键范围分区

  • 数据按分区键排序,每个分区负责一段连续区间(类似百科全书卷册)。
  • 优点:支持高效的范围扫描(如 WHERE key BETWEEN A AND B 只会落到一个分区)。
  • 致命缺点:写入热点。如果分区键是时间戳,今天的数据全压到最后一个分区。 哈希分区
  • 对分区键取哈希,使数据均匀分布到各分区。
  • 优点:消除热点,负载均匀。
  • 缺点:范围查询必须扇出到所有分区。 混合方案
  • 复合主键,第一列为哈希分区键(均匀),第二列为排序键(支持分区内范围扫描)。
  • 示例:CREATE TABLE events (user_id INT, ts TIMESTAMP, data TEXT, PRIMARY KEY ((user_id), ts)) (Cassandra)。按 user_id 哈希分区,同一用户的事件在单个分区内按 ts 排序。

3. 二级索引与分区
#

本地索引 (Document-Partitioned Index)
#

  • 每个分区只对自己数据建索引。
  • 写入快(索引更新本地化),但查询必须发送到所有分区(扇出),读延迟高且资源浪费。适合少量后台查询。 全局索引 (Term-Partitioned Index)
  • 索引本身按索引键分区,与被索引数据可能不在同一节点。
  • 查询只访问相关分区,速度快。
  • 写速度慢:更新索引需要跨分区事务(分布式事务),一致性维护昂贵。 落地实践
  • Cassandra 只支持本地索引。
  • MongoDB 全局索引 + 本地索引可选。
  • 关系型分库分表中间件(如 ShardingSphere)通常通过配置全局索引表实现。

4. 再平衡 (Rebalancing)
#

目标:节点增减时,数据移动量最小,对系统影响最小。 策略对比

  • hash mod N:绝对禁止!节点数变化会导致几乎所有数据重新映射,雪崩式迁移。
  • 固定数量分区:创建远超节点数的分区(如每节点1000个分区),节点加入时只需移动部分分区,数据移动量正比于分区数。Riak, Couchbase, Voldemort 采用。
  • 动态分区:范围分区时,分区达到阈值就分裂;合并小分区。Cassandra, HBase 采用。
  • 一致性哈希 + 虚拟节点:Cassandra 的 num_tokens 设定每节点虚拟节点数,通过移动虚拟节点完成再平衡。 运维命令 (Cassandra): bash

5. 请求路由
#

三种方式
#

  1. 客户端直连任意节点,节点充当协调者转发。Cassandra 就是此模式。
  2. 通过路由层转发,如反向代理 / ZooKeeper 做路由注册。
  3. 客户端缓存分区映射,直接连到目标节点。如 MongoDB 驱动从 config server 拉取分片元数据。 具体示例 (etcd 维护路由表)
  • 键值:/partitions/1 -> 10.0.0.1:3306
  • 客户端 watch etcd,分区变化时更新本地缓存。
  • 路由表变更时短暂一致性读取没问题,可容忍最终一致。

第6章面试题(3道)
#

面试题1(直接关联)
#

题目:使用用户ID哈希分库分表后,需要根据邮箱查询用户,如何实现? 常规回答:
建立一个独立的“邮箱→用户ID”映射表,可以是一个集中式表或者全局索引,先查映射表得到用户ID,再定位到对应分片获取完整数据。 进阶回答(考虑一致性、缓存和异构索引):

  1. 全局索引方案
  • 维护一张 email_mapping 表,字段 email, user_id,分区策略可以用邮箱哈希,保证分布均匀。
  • 写操作:注册用户时,在同一事务中分别写 user 表和 email_mapping 表。如果用分库中间件,需支持分布式事务(如 ShardingSphere 的 XA/Seata)。如果强一致不可接受,可异步写映射表,但会有短时间查询不到用户的风险。
  • 查询流程:请求带邮箱 → 查 email_mapping → 得到 user_id → 路由到用户库拿完整信息。
  1. 外挂搜索引擎
  • 将用户信息实时同步到 Elasticsearch,邮箱作为文档字段。
  • 查询直接走 ES 得到 user_id,再查数据库。ES 索引更新通过 CDC 保证最终一致。
  • 这种方式解耦,支持模糊搜索、多条件筛选。
  1. 缓存加速
  • 对于高频邮箱查询,建立 Redis email -> user_id 缓存,TTL 短。缓存未命中降级查映射表。
  1. 一致性设计
  • 如果必须强一致(如登录),可以设计成邮箱哈希映射到固定分片,将用户数据也按该分片存放,这样邮箱即可直接路由。但修改分片策略代价太大。折中:读映射表时允许短时延迟,用重试保证。
  1. 最终推荐:对于读多写少的场景,ES 索引最为灵活,配合映射表作为兜底,实现最终一致。

面试题2(原理相关)
#

题目:为什么 hash(key) % N 做分区是一种灾难?应该用什么替代? 常规回答:
节点数 N 变化时,几乎所有数据都要重新映射并移动,导致服务不可用和大量 I/O。应该用固定分区数或一致性哈希。 进阶回答(结合具体算法和业务影响):

  1. 数学原因:取模运算将 key 映射到 [0, N-1]。当 N 变成 N+1 时,原来 hash(key) % N 的位置大部分会改变。比如 N=3 变 N=4,同余的概率只有 1/4,75% 的 key 需要迁移。在 TB 级数据量下是不可接受的。
  2. 正确方法:固定分区数
  • 假设预先创建 10000 个逻辑分区 (partition 1~10000),映射关系 partition = hash(key) % 10000
  • 每个节点负责一部分 partition。扩容时,只需将部分 partition 迁移到新节点,数据移动量只是 总数据量 * (迁移分区数/10000),远小于全量。
  • 实现时一个分区对应一个数据表,如 orders_001 ~ orders_100,路由表记录 分表号 -> 数据库
  1. 一致性哈希
  • 将哈希空间形成一个环,每个节点占据多个虚拟节点。key 落在环上顺时针找到第一个节点。
  • 增删节点时,只有环上相邻节点间的数据需要迁移。Cassandra 用 num_tokens=256 给节点分配虚拟节点。
  • 但仍需注意数据倾斜,虚拟节点越多分布越匀,但维护成本也高。
  1. 业务影响:不合理的分区迁移会导致大量数据跨网络拷贝,磁盘带宽打满,在线请求超时。所以设计之初就必须选择可平滑扩展的方案。

面试题3(类似场景题,综合)
#

题目:设计一个高并发的秒杀系统,你如何对订单和库存进行分库分表? 常规回答:
按用户 ID 哈希对订单分片;库存因为是热点,通常单独用 Redis 缓存,不在关系库分片。 进阶回答(考虑热点、事务隔离、回滚和扩展):

  1. 订单表分片:按 user_id 哈希分成 64 个库,每个库多张表。由于用户 ID 分布相对均匀,订单表写入负载分散。
  2. 库存扣减
  • 不能用一般关系库分片,因为商品库存是争抢的热点。加库存行锁 (SELECT FOR UPDATE) 会造成大量阻塞。
  • 解决:用 Redis 做库存预减。DECR 原子操作,当 Redis 库存扣到 0 立即拒绝后续。扣减成功的请求写入 Kafka,异步消费来落订单库和真实扣库存。
  • 最终一致:如果订单取消,通过补偿消息恢复 Redis 库存,同时记入库存流水表。
  1. 分片与事务
  • 下单操作涉及:减库存(Redis),插入订单(按 user_id 分片),扣减资金(按 user_id 分片)。资金和订单在同一个分片(同一 user_id 下),可以用本地事务保证一致性。跨分片的部分(比如卖家增加收入,不同分片)通过异步消息最终一致。
  1. 再平衡与容量规划
  • 秒杀期间流量数十倍,之前规划的分片数可能不足。可以在秒杀开始前用“预分片”,将订单库临时扩到 128 实例,用固定分区法提前分配好。秒杀结束后再逐步缩容。
  1. 监控:监控每个分片的 QPS、CPU、磁盘 IOPS,如果某个用户 ID 极度活跃(大 V 参与),可能产生单分片热点,可对该用户 ID 加盐分散写入(user_id_suffix),读的时候聚合。
  2. 关键点:秒杀系统分片不仅要均衡负载,更要将热点分离到缓存和队列,分片层只做持久化和最终确认,不在锁竞争上浪费时间。
  3. 这个问题老让我想起来小程序点单:
  • 1. 悲观锁(Pessimistic Lock)

思路:觉得冲突很容易发生,所以操作前先锁住,不让别人动。
类比:你在本子上记菜单,记之前先把本子抢过来锁进抽屉,写完再放回去。别人只能排队等。
优点:肯定不丢。
缺点:如果忘了开锁,所有人都卡死;并发性能差,因为大家都在排队等。
在购物车里怎么用:你加菜时,服务器把这个购物车的记录锁住,朋友必须等你操作完并解锁后才能加菜。在点多菜、网络延迟高的时候,体验会很卡。

  • 2. 乐观锁(Optimistic Lock)

思路:觉得冲突是小概率事件,我先让你改,但改的时候要检查一下,有没有人在我读之后动过这个数据。
类比:你在共享文档上修改前,先记下现在是“第3版”。改完准备保存时,发现文档已经变成“第4版”了,系统就会提示你:“别人已经改过了,请基于最新版重改。” 如果还是“第3版”,说明没人动过,你的修改就能顺利保存,并让版本号变成“第4版”。
优点:不用排队,无锁等待,高并发下性能非常好,只在极少数冲突时才会失败重试。
缺点:发生冲突时需要客户端处理重试逻辑,比如重新读取最新购物车,再尝试添加。

这就是版本号机制的核心,也是乐观锁最常用的实现方式。

第七章:事务
#

1. ACID 与隔离级别全景
#

  • 原子性 (Atomicity):一个事务要么全做,要么全不做。回滚靠 undo log。
  • 一致性 (Consistency):业务约束。例如总余额不变,由应用和数据库共同保证。
  • 隔离性 (Isolation):并发事务互不干扰。就是本章核心。
  • 持久性 (Durability):提交的数据不丢,靠 redo log/WAL。

隔离级别与并发异常对应表

隔离级别脏读不可重复读幻读更新丢失写倾斜
读未提交
读已提交
可重复读(快照)✘(部分)
串行化
  • MySQL 默认:REPEATABLE READ;PostgreSQL 默认:READ COMMITTED,但支持 REPEATABLE READSERIALIZABLE(基于 SSI)。

2. 读已提交 (Read Committed)
#

防什么:脏读、脏写。

实现

  • 脏写:使用行级写锁,事务更新一行时持有排他锁直到事务结束,另一个事务写同一行需等待。
  • 脏读:通过 MVCC,事务读取数据时看到的是该行在事务开始前的已提交版本,不会读到未提交的修改。PostgreSQL 中每个元组有 xminxmax,用来判断可见性。

示例(PostgreSQL MVCC 可见性规则)

sql

-- 事务A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- xmin = A, 未提交
-- 事务B (此时读)
SELECT balance FROM accounts WHERE id = 1; -- 会看到旧版本,因为 A 未提交

3. 快照隔离 / 可重复读 (Snapshot Isolation)
#

防什么:不可重复读(一个事务内两次读同一行,值不同)。

机制:事务开始时获取一个快照,整个事务期间看到的数据视图一致。MVCC 维护多个版本,每个版本标记创建和删除事务 ID。通过快照判断可见性。

并发异常:更新丢失 (Lost Update)
两个事务并发“读-修改-写”同一行时,后写的覆盖了先写的。

SQL 例子

sql

-- 事务A: 读 counter=10
-- 事务B: 读 counter=10
-- 事务A: UPDATE SET counter = 10 + 1 (=11) 提交
-- 事务B: UPDATE SET counter = 10 + 1 (=11) 提交,覆盖了A的更新,丢失一次增加

防止方法

  • 原子写UPDATE counters SET cnt = cnt + 1 WHERE ...,不需要先读。
  • 显式锁SELECT ... FOR UPDATE,锁定行直到事务结束。
  • 乐观并发控制:用版本号 UPDATE ... SET cnt = cnt + 1, version = version + 1 WHERE version = ?,若影响 0 行则重试。

4. 写倾斜 (Write Skew) —— 快照隔离的漏洞
#

定义:两个事务并发读满足条件的一组行,然后根据读到的结果各自更新不同的行,但总体违反了全局约束。

经典场景:值班至少一人

sql

-- 初始状态:医生 Alice 和 Bob 都在 on_call
-- 事务 T1: SELECT * FROM doctors WHERE on_call = true;  -- 得到 Alice, Bob (2人)
-- 事务 T2: 同样得到 Alice, Bob
-- T1: UPDATE doctors SET on_call = false WHERE name = 'Alice'; -- 认为还有Bob
-- T2: UPDATE doctors SET on_call = false WHERE name = 'Bob'; -- 认为还有Alice
-- 两个事务提交,结果没人值班!

原因:快照隔离中,两个事务的 SELECT 读到的是各自的快照,互不冲突,所以都允许执行。但它们修改的行不同,锁不冲突。

解决

  1. SELECT ... FOR UPDATE 锁定所有符合条件的行(注意:必须锁所有读到的行,而不只是修改的行)。
  2. 升级到串行化隔离级别(SSI)。

5. 实现串行化的三种方式
#

A. 两阶段锁 (2PL)
#

  • 原理:事务分为“扩展阶段”(只能获取锁)和“收缩阶段”(只能释放锁)。锁包括共享锁(读)和排他锁(写),持有到事务结束。
  • 特点:读写互相阻塞,读也可能阻塞写(共享锁),导致吞吐量暴跌,死锁频繁。
  • MySQL 的 SERIALIZABLE 实现:对普通 SELECT 隐式加共享锁,变成 SELECT ... LOCK IN SHARE MODE
  • 使用场景:冲突极高,且事务极短,或对并发要求极低的极小范围。

B. 可串行化快照隔离 (SSI)
#

  • 原理:乐观的并发控制。仍然使用快照隔离 + MVCC,但在提交时检测是否有“违背串行化”的冲突,若有则回滚其中一个事务。
  • 实现:追踪事务之间的读写依赖,如果一个事务读了另一个并发事务将要修改的过时数据,就可能有写倾斜,提交时检测并中止。
  • PostgreSQL 9.1+ 的 SERIALIZABLE 就是 SSI。
  • 优点:无锁阻塞,读不阻塞写,写不阻塞读,无死锁。性能远高于 2PL。
  • 成本:必须处理“序列化失败”的错误并重试。

使用 PostgreSQL SSI 示例

sql

BEGIN ISOLATION LEVEL SERIALIZABLE;
-- ... 读写操作 ...
COMMIT; -- 如果失败,返回 "could not serialize access"

应用层需捕获 40001 SQLSTATE 错误并重试整个事务。

C. 实际串行执行
#

  • 去除并发,单线程执行所有事务。只有内存数据库、极小事务适合(如 Redis 单线程, VoltDB)。现代数据库几乎不单独用。

6. 事务实战建议
#

  • 默认使用快照隔离 (PG READ COMMITTED + 小心更新丢失;MySQL REPEATABLE READ)。
  • 更新丢失防御:永远用原子 UPDATE 或版本号,别“读-改-写”。
  • 写倾斜识别:凡是代码出现“先 SELECT 满足某条件,再根据 SELECT 结果决定 INSERT/UPDATE”的模式,都要警觉。要么 FOR UPDATE,要么上 SSI。
  • SSI 就绪:在关键业务点(如预约、库存扣减)开启 SERIALIZABLE,并封装好重试逻辑。

重试骨架 (Python)

python

for i in range(3):
    try:
        with conn, conn.cursor() as cur:
            cur.execute("BEGIN ISOLATION LEVEL SERIALIZABLE")
            # ...
            cur.execute("COMMIT")
            break
    except psycopg2.errors.SerializationFailure:
        time.sleep(random.random()*0.1)

第7章面试题
#

面试题1(直接关联)
#

题目:解释“写倾斜”,给出一个你在设计系统时可能遇到的场景,以及如何防止。

解答
常规回答:写倾斜是快照隔离下的一种并发异常,两个事务并发读取满足条件的行,然后根据读取结果修改不同的行,但违反了整体约束。例如值班调度中两人同时请假导致无人值班。解决方法是用 SELECT FOR UPDATE 锁定所有符合条件的行,或使用串行化隔离级别。

进阶回答

  • 场景扩展:预订会议室的槽位,一个槽位只能被一个预订占用。快照隔离下事务 A 查得 slot X 空闲,事务 B 也查得 slot X 空闲,二者同时插入预订记录(不同的行),导致 slot X 被双订。
  • 分析:快照隔离只看修改的行是否冲突,这里插入的记录是不同行,所以彼此不阻塞。但全局约束“每个 slot 只有一个预订”被违反。
  • 解决方案权衡
    • SELECT FOR UPDATE ON slots:需锁定 slot 行,保证后续插入串行化。但若 slot 表热,吞吐下降。
    • SSI:无需显式锁,PostgreSQL 会检测到“反依赖”并中止后提交的事务。应用层重试,写冲突少的业务效率高。
    • 物化冲突:增加一个约束表 booking_lock,按 slot 唯一键进行 INSERT,利用唯一约束冲突来防并发,失败则回滚。实际上也是一种悲观锁但细化到业务层。
  • 结论:识别写倾斜需要审查所有“读-判断-写”模式的代码,并决定是加锁还是用 SSI 提升吞吐。

面试题2(原理相关)
#

题目:比较两阶段锁(2PL)和可串行化快照隔离(SSI)的优缺点。为什么现代数据库更倾向于 SSI?

解答
常规回答:2PL 基于锁,读也可能阻塞写,死锁多,吞吐量低。SSI 是乐观的,不加锁,冲突时回滚,吞吐高。现代业务冲突率低,所以 SSI 更合适。

进阶回答

  • 锁开销:2PL 要求读加共享锁,写加排他锁,且锁持有到事务结束。即使两个事务读不同行,也可能因为页级锁或索引间隙锁产生冲突。高并发下锁竞争严重,连接池满。
  • 死锁:2PL 死锁频繁,需要死锁检测或超时回滚,浪费资源。SSI 无死锁。
  • 乐观假设:SSI 基于“冲突是罕见的”假设。在大多 OLTP 中,写倾斜发生的概率极低,因此回滚成本可接受。SSI 还避免了锁管理的开销。
  • 实现复杂度:SSI 依赖 MVCC 提供的快照,只需在提交时增加冲突检测,比精细的锁管理器简单,且与快照隔离共存。
  • 场景取舍:如果系统冲突极高(如秒杀一个商品的库存,大量并发更新同一行),SSI 会大量回滚,此时 2PL 可以保证串行执行,反而更稳定。但这种热点应通过业务设计(排队、预热、异步)化解,而不是依赖数据库锁。
  • 未来趋势:PostgreSQL 从 9.1 开始用 SSI,MongoDB 4.0+ 也支持 snapshot reads 与 conflict detection。云原生数据库如 CockroachDB 也基于乐观事务。SSI 是现代分布式数据库的基石。

面试题3(类似场景题)
#

题目:设计一个支持“并发预订座位,不能超售”的电影票系统,你会如何选择隔离级别和锁定策略?

解答
常规回答:使用串行化隔离级别,或对座位行加 SELECT FOR UPDATE,保证同一个座位不会被多人预订。

进阶回答

  1. 数据模型:座位表 seats(show_id, seat_no, status),主键 (show_id, seat_no)
  2. 隔离级别:默认 REPEATABLE READ 不够,因为两个事务同时查到某个座位空闲,会同时插入订单,导致写倾斜。
  3. 悲观方案SELECT * FROM seats WHERE show_id=1 AND seat_no='A1' FOR UPDATE。这锁定了该座位行。然后检查状态,若空闲则更新为“已预订”,并插入订单。由于排他锁,并发事务会被阻塞。性能上,每座位每次只能一个事务操作,秒杀时压力大。
  4. 乐观方案(SSI):在 SERIALIZABLE 隔离级别下执行,不再 FOR UPDATE。如果并发冲突,PostgreSQL 会中止后提交的事务。我们需要捕获错误并重试。对同一座位,冲突率较高,重试可能多次,但换来了无锁的高并发。
  5. 超卖防御的额外防线:在应用层加 Redis 预占位,SET seat_key EX 10 NX 原子锁。拿到锁的请求再去 DB 写,未拿到的直接拒绝。该方案把竞争前置到 Redis,数据库压力小。
  6. 最终一致检查:定时核对订单数和座位被占数,若有超卖则走人工处理+退款。数据层面,通过唯一约束 UNIQUE (show_id, seat_no) 也作为最后防线,插入订单表时会违反约束。
  7. 结论:采用“Redis 原子锁 + DB SELECT FOR UPDATE / SSI + 唯一约束”多层防护,是最稳固的超卖防线。

第8章:分布式系统的麻烦
#

1. 部分失效 —— 分布式系统的本质困难
#

单机:要么正常工作,要么崩溃。分布式:部分组件可能故障,其他正常运行,这是所有复杂性的根源。

必须接受:网络不可靠、时钟不同步、节点可能无限期暂停。


2. 不可靠网络
#

  • 消息可能丢失、延迟、乱序。
  • 无法区分“网络断开”“节点挂了”“节点太慢”。只能依赖超时
  • 超时设置:自动调整或固定。例如 gRPC 的 deadline:ctx, cancel = context.WithTimeout(ctx, 2*time.Second)
  • 后果:超时后:
    • 若请求已到达但响应丢失 → 重试导致重复执行。
    • 若请求根本没到 → 重试正确。
  • 必须实现幂等:为每个写操作生成唯一 idempotency key,服务器按 key 去重。

幂等实现示例 (基于唯一约束)

sql

CREATE TABLE idempotency (
  key UUID PRIMARY KEY,
  response JSON
);
-- 接收请求时:
BEGIN;
INSERT INTO idempotency VALUES (key, response) ON CONFLICT DO NOTHING;
-- 若插入成功,执行业务操作;否则返回已存的 response。
COMMIT;

3. 不可靠时钟
#

  • 墙上时钟 (Wall Clock):可跳变,NTP 可能会回调或跳秒。不适合测量耗时或比较顺序。
  • 单调时钟 (Monotonic Clock):不断前进,适合测量超时。System.nanoTime() (Java), CLOCK_MONOTONIC (Linux)。
  • 时钟依赖的隐患:用“最后写入胜利”冲突解决时,依赖时间戳排序,可能因为时钟偏差而丢失数据。
  • 逻辑时钟:Lamport 时间戳、版本向量,提供因果顺序,不依赖物理时钟。

正确用法

  • 用单调时钟做超时。
  • 跨节点事件排序用版本向量或逻辑时钟,绝不依靠 NTP 同步的时间戳做全序。
  • Google Spanner 的 TrueTime:通过 GPS/原子钟给出时间误差范围,在提交前等待误差,使得物理时间戳可以代表真实顺序,但绝大多数系统没有这一条件。

4. 节点假死与脑裂
#

  • GC 暂停:Java GC 可能导致进程暂停数秒甚至数十秒。期间,该节点没有响应,其他节点可能认为它死了,触发选举。GC 结束后,它又以为自己还是 leader,开始写数据(脑裂)。
  • 防护:Fencing Token
    • 锁服务(如 etcd)发放递增 token。
    • 写存储时必须带上 token,存储拒绝过期 token 的写入。
    • 实现:存储记录 last_seen_token,只有当 request_token >= last_seen_token 才接受写。

具体思路

go

// 获取锁,返回 token (zxid 或 version)
token := etcd.Lock("resource/my-lock")
// 写存储时
storage.Write("key", value, token)
// 存储端:
if token < currentFencingToken {
    return error
}
currentFencingToken = token

5. 应对部分失效的原则
#

  1. 所有重试必须幂等
  2. 超时 + 断路器:Netflix Hystrix / Resilience4j / Sentinel,熔断慢的下游。
  3. 降级与兜底:当依赖不可用时,返回缓存或默认值,而不是抛出错误。
  4. 混沌工程:用 Chaos Mesh / Litmus 注入网络延迟、丢包、kill pod,验证系统表现。

断路器配置示例 (Resilience4j)

yaml

resilience4j:
  circuitbreaker:
    instances:
      backendA:
        slidingWindowSize: 10
        failureRateThreshold: 50
        waitDurationInOpenState: 10s

第8章面试题
#

面试题1(直接关联)
#

题目:一个分布式锁的实现,为什么需要 Fencing Token?举一个因 GC 导致锁过期但写操作仍执行的例子。

解答
常规回答:如果持有锁的进程因为 GC 长停顿,锁过期释放,被另一个进程获取,当 GC 结束时原进程可能仍然认为持有锁并写入,导致数据竞争。Fencing Token 递增,写入时附带 token,存储拒绝小 token 的写,防止脑裂。

进阶回答

  • 具体场景:Redis 分布式锁(Redlock)中,Client A 获取锁,设置过期时间 5s。A 因 Full GC 暂停 10s,锁已被 Redis 删除,Client B 获取同锁。A 恢复后继续写入库存数据,覆盖了 B 的操作。
  • Fencing Token 的落地:每次成功获取锁时,锁服务返回一个单调递增的数字(fencing token)。在 ZooKeeper 中可以用 znode 的 cversionzxid;在 etcd 中可以用 ModRevision。写存储(如数据库)时,存储记录最后接受的 token。A 带着旧 token 写,存储发现 token < current,拒绝写并抛出异常。
  • 实现细节:存储可以是 MySQL 的一行,更新时加上 WHERE fencing_token < ?,如果影响 0 行表示 token 过期,回滚。这就让应用层无锁竞争的安全。
  • 为什么 Redlock 不提供 Fencing Token:因为 Redis 本身没有全局递增的 revision 暴露给客户端。需要在应用层自己结合版本号实现。
  • 总结:任何基于“租约”的锁,只要锁的持有者可能超时后继续操作,就必须有 fencing token。

面试题2(原理相关)
#

题目:为什么在分布式系统中,不能依靠 NTP 同步的墙上时钟来对事件进行排序?请给出一个会导致数据丢失的实例。

解答
常规回答:NTP 同步存在误差,时钟可能回调或跳跃,导致后发生的事件时间戳更小,从而被错误地认为先发生。例如在多主复制中用 LWW 冲突解决,如果两个节点时钟偏差 5ms,晚写的数据可能被早写覆盖。

进阶回答

  • NTP 的不确定性:NTP 能校正时钟,但受网络延迟抖动影响,同步精度通常仅 1-10ms。此外,闰秒、手动误设置、虚拟机迁移等可能导致时钟跳变数秒。
  • 数据丢失实例:多活数据中心,库 A 物理时间 10:00:00.000,库 B 同步后为 09:59:59.500(500ms 偏差)。用户 X 在 B 更新记录 key1 值=“new”,时间戳 09:59:59.600;紧接着用户 Y 在 A 更新同 key 值=“old”,时间戳 10:00:00.100。LWW 根据时间戳选择“new”保留,“old”被丢弃,但实际上 Y 的操作是后发生的,应该保留。
  • 正确做法:使用逻辑时钟(Lamport 时间戳)或版本向量,它们仅依赖于事件间的消息传递顺序,不依赖物理时间。或者使用共识算法保证全序。
  • 唯一例外:Google Spanner 使用 TrueTime 能够给出不确定区间,并通过 commit wait 保证物理时间顺序反应真实顺序,但这需要特殊硬件和时间同步基础设施。
  • 工程建议:绝不用 System.currentTimeMillis() 做跨节点的业务先后顺序决策。仅用它做日志排序或粗略判定。

面试题3(类似场景题)
#

题目:你设计的微服务间有一个跨服务的操作需要确保不重复执行,比如“支付后发送一次红包”,你怎么保证?

解答
常规回答:引入幂等键 (idempotency key)。客户端生成一个唯一 ID,服务端记录这个 key,如果重试就返回之前的结果。使用数据库唯一约束或 Redis 原子操作。

进阶回答

  1. 幂等键生成:客户端(移动端或上游服务)在发起支付请求时,生成一个 UUID 作为 X-Idempotency-Key,放入 HTTP 头。重试时使用相同的 key。
  2. 服务端处理
  • 红包服务接收到消息后,开启数据库事务。
  • 尝试向 idempotency_log 表插入 (key, response),key 有唯一约束。
  • 若插入成功,则执行业务逻辑:发送红包,记录响应。
  • 若插入冲突(重复 key),则直接返回已存的 response。
  1. 原子性:插入幂等记录和业务操作必须在同一本地事务中,保证同时成功或失败。如果业务失败,幂等记录也不能留,否则重试无法再次执行。
  2. 过期清理idempotency_log 表数据定期清理,比如只保留 7 天。过期的幂等键被视为无效,需另加业务唯一性约束。
  3. 端到端流程:支付成功消息由消息队列投递。消费者拉取消息后,提取 idempotency_key,执行上述逻辑。如果消息队列投递 at-least-once,幂等保证 exactly-once 效果。
  4. 注意:如果涉及外部调用(如调用微信红包 API),外部可能不支持幂等,需要自己记录请求状态,并在重试时先查询外部状态再决定。
  5. 监控:统计幂等冲突率,若突然升高,表示上游可能重试风暴,需要排查网络或超时配置。

第九章:一致性与共识
#

1. 线性一致性 (Linearizability)
#

定义:系统表现得好像只有一个数据副本,任何读都能反映最新写入。写入一旦成功,后续所有读都必须返回该值(或更新值)。

使用场景

  • 分布式锁:锁的获取与释放必须线性一致,否则可能多人同时获锁。
  • 唯一性约束:如用户名唯一,需要线性一致存储。
  • 选举 leader:需要全局共识,不能有双主。

代价:高延迟,且无法容忍网络分区(CAP:当分区发生时,必须牺牲线性一致或可用性)。

实现

  • 共识算法(Raft, Paxos)可以直接提供线性一致存储。
  • 在 etcd 中,使用 Serializable 读可能返回旧值,Linearizable 读保证最新。

2. 共识算法 (Raft)
#

目标:让一个集群的多个节点对某个日志序列达成一致,即使存在节点故障和网络延迟。

Raft 核心机制

  • 领导选举:节点有三种状态 Follower, Candidate, Leader。任期 (term) 单调递增。每个 term 最多一个 leader。
  • 日志复制:Leader 接收客户端请求,追加日志条目,并复制到 Follower,当多数节点确认后提交。
  • 安全:选举限制:Candidate 的日志必须至少和大多数节点一样新才能当选。Leader 只提交当前 term 的日志条目(借助 no-op 条目)。

etcd 使用 Raft

bash

# 读取一个 key,要求线性一致
etcdctl get /mykey --consistency="l"
# 比较并交换(原子 CAS),可用于锁
etcdctl txn --compare="value(/lock) == ''" --then="put /lock holder1" ...

3. 两阶段提交 (2PC) 与 XA 事务
#

目的:跨多个数据库或消息队列的原子提交。

协议流程

  1. 应用程序请求协调者开始事务。
  2. 协调者向所有参与者发送 Prepare 请求。
  3. 参与者执行操作,写入 undo/redo 日志,回复 Yes (准备提交) 或 No (中止)。
  4. 若全部 Yes → 协调者写 commit 日志,发送 Commit 给所有参与者。
  5. 任一 No → 协调者发送 Abort
  6. 参与者根据指令提交或回滚,释放锁。

致命问题:协调者故障

  • 如果协调者在 Prepare 之后、Commit 之前崩溃,参与者处于 prepared 状态,持有锁,无法独立决定提交或中止,只能无限阻塞等待协调者恢复。这是 阻塞协议
  • XA 事务通过数据库支持 2PC,如 XA PREPARE, XA COMMIT。问题相同。

监控与恢复:必须有工具监控处于 prepared 状态的悬挂事务,并手动决定提交或回滚(需要知道协调者的决策)。

为什么尽量避免

  • 锁持有时间长,影响并发。
  • 协调者单点,需要高可用但实现复杂。
  • 现实中的跨系统异构分布式事务几乎不用 2PC,而用 Saga。

4. Saga 模式 —— 2PC 的替代
#

思想:将一个长事务拆成多个本地事务。每个本地事务有对应的补偿事务(撤销逻辑)。通过顺序执行或编排,最终达到最终一致性。

编排方式

  • Choreography:各服务通过事件相互触发,松散耦合。
  • Orchestration:一个 Saga 编排器集中指挥。

例子:订单 Saga

  1. 订单服务:创建订单 (状态=PENDING)。
  2. 库存服务:预留库存。补偿:释放库存。
  3. 支付服务:扣款。补偿:退款。
  4. 订单服务:标记订单为 CONFIRMED。

失败处理:如果支付扣款失败,Saga 编排器逆序执行补偿:释放库存 → 订单取消。

幂等要求:每个步骤和补偿操作都必须幂等,因为网络重试。

实现框架:Seata Saga 模式,Eventuate Tram,Apache ServiceComb Pack。


5. 共识在实践中的应用
#

  • etcd / ZooKeeper / Consul:用于服务发现、配置管理、分布式锁、leader 选举。它们通过 Raft/Paxos 提供小数据集的线性一致存储。
  • NewSQL 数据库:TiDB, CockroachDB 使用 Raft 来复制每个分片的数据,并提供分布式事务。
  • 消息队列:Kafka 使用 Raft (KRaft) 取代 ZooKeeper 管理元数据。

选型建议

  • 跨服务数据一致性:Saga + 事件溯源。
  • 需要强一致的小状态(如分布式锁、选主):etcd。
  • 大量数据的分布式事务:选择原生基于共识的 NewSQL,而不是自己拼 2PC。

第9章面试题
#

面试题1(直接关联)
#

题目:详述两阶段提交 (2PC) 的过程,并说明为什么在云环境中它被认为是一种危险且应尽量避免的协议。

解答
常规回答:2PC 包括准备阶段和提交阶段。参与者 prepare 后等待协调者决策。若协调者崩溃,参与者会处于阻塞状态,锁资源无法释放。云环境中节点可能随时重启或网络分区,协调者恢复不可预期,导致系统卡死。

进阶回答

  • 详细流程:以跨库转账为例,两个银行数据库参与事务。协调者发 prepare,两个库记录 redo/undo 并返回 yes。协调者写本地 commit log 后发 commit 请求。如果协调者在写了 commit log 之后、发出 commit 之前崩溃,事务未完成。
  • 阻塞的本质:参与者 prepare 后,不能单方面中止,因为协调者可能已经收到所有 yes 并决定提交(只是还没发)。因此参与者必须无限期等待,持有锁。这就是 2PC 的“阻塞”特性。
  • 云环境中的风险
    • 云服务器的 ephemeral IP、随机重启、AZ 故障,协调者可能永远回不来。
    • 即使有协调者高可用方案(如基于共识的协调者),其恢复过程复杂,仍可能留下悬挂事务。
    • 悬挂事务积累会导致数据库连接池满、锁表,拖垮整个系统。
  • 替代方案:使用 Saga 模式。将全局事务变成本地事务链 + 补偿。避免了全局锁和协调者单点。虽然开发复杂度上升,但可用性大幅提升。
  • 例外情况:如果数据规模小,都在同一个支持分布式事务的 NewSQL 数据库内(如 TiDB),内部通过 2PC + 共识优化,可避开传统 XA 的问题。但跨异构系统仍坚决避免 2PC。

面试题2(原理相关)
#

题目:Raft 共识算法是如何保证即使发生网络分区,也不会出现双主脑裂的?

解答
常规回答:Raft 通过任期 (term) 和选举限制来保证一个 term 内最多一个 leader。Leader 需要获得多数节点的选票才能当选,因此在一个分区内不可能有两个分区都获得多数票。旧的 leader 发现更高 term 会自动退位。

进阶回答

  1. 任期机制:每个节点维护 currentTerm,单调递增。选举时,candidate 增加自身 term,请求投票。每个节点在同一个 term 内最多投一票。
  2. 多数派原则:要成为 leader,必须获得集群中 多数节点 的投票(N/2+1)。网络分区后,即使分裂成两部分,最多只有一个分区包含多数节点,所以只有那个分区能选出 leader。另一个分区无法满足多数,无法选出 leader,旧 leader 心跳也收不到多数回复,变为 follower。
  3. 日志完整性限制:投票时,candidate 的日志必须“至少和大多数节点一样新”,否则拒绝投票。这避免了缺少已提交日志的节点成为 leader。
  4. Leader 退位:如果一个 leader 发现自己 term 低于其他节点(心跳回复中包含更高 term),立即转为 follower。
  5. Fencing token 的额外保障:虽然 Raft 不会脑裂,但客户端和存储之间仍可能因 GC 导致过期 leader 写,因此配合 fencing token(例如 Raft log index)可彻底防止。
  6. 实践验证:用 Jepsen 测试 etcd 的 Raft 实现,验证网络分区、时钟异常下的一致性。
  7. 总结:Raft 通过 term + majority vote + log completeness 三重保障,即使在极端网络下也不会产生两个冲突的 leader。

面试题3(类似场景题)
#

题目:你有一个系统,需要跨订单服务、库存服务、支付服务完成下单操作,要求数据一致性。你会怎么设计?为什么不用 2PC?

解答
常规回答:使用 Saga 模式。每个服务执行本地事务,如果某个步骤失败,则反向调用补偿事务。不用 2PC 是因为跨服务 2PC 会长时间锁定资源,且协调者故障导致系统卡死。

进阶回答

  1. 架构选择:采用 Orchestration Saga。订单服务作为 Saga 编排器。
  2. 正向流程
  • (1) 订单服务:BEGIN; INSERT INTO orders ... COMMIT。订单状态 CREATING
  • (2) 调用库存服务:POST /inventory/reserve。内部事务扣减库存,记录预留记录。返回预留 ID。
  • (3) 调用支付服务:POST /payment/pay。内部事务扣款,记录流水。返回支付 ID。
  • (4) 订单服务:更新订单状态为 CONFIRMED
  1. 补偿流程:如果 (3) 支付失败,Saga 编排器调用库存服务 POST /inventory/release 释放预留,订单状态更新为 CANCELED。补偿接口必须幂等(通过预留 ID 防重)。
  2. 为什么不用 2PC
  • 这些服务各自有独立的数据库,2PC 需要它们支持 XA 协议,且与数据库耦合。
  • 锁持有时间长:库存扣减后,如果等待支付确认,库存行被锁,影响并发。
  • 可用性:一旦协调者宕机,预留的库存锁无法释放,导致全网不可用。Saga 不存在全局锁。
  1. 可靠性保障
  • 必须记录 Saga 的每一步状态到数据库 (outbox),以便编排器崩溃恢复。
  • 使用消息队列异步驱动,步骤之间最终一致。
  • 提供“重试”和“手动回滚”的管理界面。
  1. 最终结论:分布式系统中,放弃 ACID 拥抱 BASE,Saga 是跨服务数据一致性的工程标准。2PC 仅限单个数据库内部的跨表事务,或已在 NewSQL 中内部使用。
  2. 这个问题我实际上觉得还是不够深入,让我来再深入一些:
  3. 如何分片? 1. 按订单 ID 分片
    • 订单服务天然适合,一个订单的所有操作(创建、支付、取消)都落在同一分片。
    • 问题:库存扣减时,商品 A 的库存可能不在同一分片,导致跨分片操作。 2. 按用户 ID 分片
    • 用户的所有订单、支付记录、库存预留都可能路由到同一分片,这样 Saga 的多数步骤可以变成一个本地事务,极大简化。
    • 问题:热点用户、库存扣减可能需跨分片(如果商品属于不同卖家),且订单号必须包含分片信息,对查询不友好。
  4. 涉及金额怎么办? 1. 正向扣款:必须记录流水,并生成全局唯一交易号(如支付流水号),状态为“处理中”。 2. 补偿退款:退款是新的交易,生成新的退款流水号,并关联原扣款流水。金额必须精确计算(考虑分、优惠券分摊)。 3. 防悬挂:扣款成功后如果网络超时,Saga 可能发起补偿退款,此时扣款可能已成功,退款必须在确认扣款成功后才执行,否则可能变成“没扣款却退了钱”。
  5. 付款失败了怎么办?怎么保证 exactly once? 付款失败有两种:明确失败(余额不足)和不确定(超时)。 - 明确失败:Saga 直接触发补偿释放库存,订单取消。 - 不确定/超时:这是最棘手的。
    • 策略:支付服务在调用支付网关时,必须传入幂等键(如订单 ID + 支付请求序号)。支付网关保证同一个幂等键只处理一次。
    • 问题:后台自己重试支付,用户也点“重新支付”,可能重复扣款。 核心解法:给支付操作分配一个“业务唯一键”
      • 首次支付时,后台生成一个支付请求 ID(如 pay_req_<orderId>_<序号>,或 orderId 本身如果只有一笔支付)。
      • 将此 ID 作为幂等键传给支付服务(或支付网关)。
      • 后台重试:使用相同的幂等键。支付网关确保同一幂等键的请求只处理一次(扣款后返回缓存结果)。
      • 用户手工重试:这必须被视为新的支付意图,所以应生成新的支付请求 ID(如 pay_req_<orderId>_2)。服务端需判断原支付是否已成功:
        • 如果原支付已成功,则提示“订单已支付”。
        • 如果原支付明确失败,则允许新支付,并作废旧的支付请求。
        • 如果原支付状态未知(超时悬空),则新支付请求必须等待或取消前一个悬空请求,否则可能双重扣款。可设计“支付锁”:订单上记录当前活跃支付请求 ID,只有该 ID 完结或超时后,才能接受新支付。
    • 如果超时,Saga 不能立即补偿,而是进入等待状态,定时主动查询支付网关该笔订单的支付结果(查询接口也需幂等)。
    • 如果查询成功,按成功流程走;如果查询明确失败,则补偿释放;如果查询也超时,这时候走dlq处理方法:
      • 则需要人工介入或延迟重试,绝不可简单补偿了事,否则可能造成已扣款却把库存释放了。
      • 每个 Saga 步骤要有超时时间,比如调用支付服务 30 秒,或等待支付回调 5 分钟。
      • 超时处理: 1. 主动查询:编排器定期查询支付状态(指数退避重试查询)。若查询也超时,则挂起。 2. 补偿回滚:如果业务允许,可对超时步骤执行补偿(释放库存、取消订单)。但支付扣款超时属于“不确定”状态,不能轻易补偿,否则可能导致已扣款却退回库存。 3. 进入挂起状态:订单状态变为 PENDING_MANUAL,该 Saga 暂停,发送告警。 4. DLQ(死信队列):将无法自动处理的订单详情(包含 sagaId, 当前步骤, 上下文)投递到专门的 DLQ topic,由人工系统消费。 Exactly-once 在 Saga 中的实现**: 订单服务):使用本地事务表(Outbox)。在同一本地事务中,写入订单数据和一条待发送的“库存预留”消息到 outbox 表。一个后台线程读取 outbox 发送到 MQ,发送成功则删除。保证消息一定会被发送一次且仅一次(通过消息ID去重)。 库存服务):处理消息时,以消息 ID(或预留 ID)为唯一键,先插入一条处理记录(如 reserve_record),然后扣库存。如果消息重复,唯一键冲突即跳过。实现“消费端幂等”。 aga 步骤**:每一个参与的服务都实现幂等(如支付用外部幂等键,库存用预留ID),编排器通过重试未完成的步骤(使用状态机)达到最终一致。

面试题3(类似场景题)
#

题目:有一个每天 100TB 的日志数据,你需要定期计算各类聚合指标并提供查询,如何设计批处理管道?

解答
常规回答:将日志存储在 HDFS/S3 上,用 Spark 每天运行批处理作业,按维度聚合,结果写入 OLAP 引擎如 ClickHouse 或 Parquet 文件供查询。

进阶回答

  1. 数据摄入:日志通过 Kafka 实时收集,同时归档到对象存储 (S3),格式为 Parquet,按天分区。
  2. 批处理作业:每天凌晨运行 Spark SQL,读取前一天的原始数据,做 ETL 清洗,按业务维度预聚合(如按小时、地域、设备),生成轻度汇总表。
  3. 增量 vs 全量:如果历史数据可能被纠正,每天可只重跑近 3 天的数据,用 INSERT OVERWRITE 覆盖分区。更早数据不重跑。
  4. 存储优化:用 Parquet 列存 + Snappy 压缩,按 date, hour 分区,并基于常用过滤列做 SORT BY 提高谓词下推和压缩比。
  5. 查询服务:结果写入 ClickHouse MergeTree,按天分区,物化视图支持进一步上卷。BI 工具连 ClickHouse。
  6. 管道编排:Airflow DAG:Kafka 数据就绪检查 → Spark ETL → 数据质量校验 (Great Expectations) → 写 ClickHouse → 通知下游
  7. 容错:Spark 作业开启动态资源分配,中间结果 checkpoint 到 HDFS,失败自动重试。如果持续失败,发警报。
  8. 成本控制:使用竞价实例,并配置超时和回退到按需实例。

第十章:流处理
#

UNIX系统哲学:

  • 统一接口
  • 逻辑和布线分离
  • 透明和测试

MapReduce 思想
#

面试遇到海量数据/分布式处理问题,核心就是分治。如何思考呢?

第一步:判断规模
#

问自己:“数据量能装进内存吗?”

  • 能 → 直接内存算法(堆、哈希表、排序)。
  • 不能 → 进入分治模式。
第二步:设计 Map 职责(局部处理)
#

你希望每个工作单元独立做什么?

  • 提取需要的信息
  • 局部计数/局部排序/局部 Top K
  • 输出 (key, value)

核心问题:“我能把原问题拆成完全不依赖他人的小任务吗?”

第三步:决定中间数据的分组策略(相当于 Shuffle)
#

如何让相同 key 的数据聚在一起?

  • 哈希分组:相同 key 一定在同一机器(适合求和、计数)
  • 范围分组:按区间分,适合需要全局顺序的场景
  • 广播小表:如果某个数据量小,直接复制给所有工作单元(Map 端 JOIN)
第四步:设计 Reduce 职责(全局聚合)
#

如何从分组后的数据得到最终结果?

  • 对每个 key 做最终聚合(加总、取 Top K)
  • 如果数据仍大,可能需要多轮 Reduce,或者先局部 Top K 再汇总

MapReduce例题
#

下面的题目都是一些套路,如果能直接放在内存操作就放内存,不能再考虑外部排序。

  • 题型 1:海量数字,取最大的 5 个(或最大的 K 个)
    • 数字太多,一台机器放不下 → 拆成多份。
    • 每台机器读自己那份,在内存里用小顶堆(大小 K)求出局部 Top 5
    • 把所有局部 Top 5 汇总到一台机器,再对这些数字用小顶堆求全局 Top 5
  • 题型 2:海量 URL,求访问次数最多的前 5 个,**最简单解法(两轮 MapReduce)**哈希分组保证同一 URL 全在一处,能正确算出总次数。Top 5 再用分治法收尾。
    • 第一轮:统计每个 URL 的次数
      • 把日志分给多台机器。
      • 每台机器提取 URL,输出 (URL, 1)
      • 哈希分组:相同 URL 自动分配到同一台机器。
      • 那台机器把同一个 URL 的所有 “1” 加起来,得到 (URL, 总次数)
    • 第二轮:找出次数最多的前 5 个
      • (URL, 次数) 反转成 (次数, URL)
      • 让一台机器收集所有数据,按次数排序,取前 5。
      • 如果数据还太多,每台先求局部 Top 5,再汇总取全局 Top 5(同题型 1)。
  • 题型 3:海量数字,找中位数
    • 将整数范围切成多个区间(比如 0–99, 100–199…)。
    • 遍历全部数字,统计落在每个区间的个数(桶计数),内存只存计数器,极小。
    • 按计数器累加,找到中位数落在哪个区间。
    • 再遍历一次,只关注这个区间的数字,用内排序或堆找到中位数。
  • 题型 4:海量数据,判断某个数字存不存在 / 查重
    • 位图法(数字范围有限时)
      • 用比特位代表每个数字。
      • 40 亿个 int 只用 512MB 内存就能记录所有出现。
      • 查重时看对应的比特位是否为 1。
    • 布隆过滤器(允许极低误判时)
      • 用多个哈希函数映射到比特数组。
      • 说不存在,100% 不存在;说存在,可能极低概率误判。
      • 内存用量极省。
    • 哈希分流法(任意数据)
      • 将数据按哈希分到多台机器。
      • 相同的数据一定在同一台机器。
      • 每台机器独立查重,不存在跨机重复问题。
  • 题型 5:海量日志,统计独立 IP 访问数(UV)
    • 把所有 IP 按哈希分配到不同机器/文件。
    • 每台机器对自己拿到的 IP 集合去重(内存或用磁盘)。
    • 各台机器的 UV 直接相加就是全局 UV。

为什么对
哈希保证相同 IP 只去一台机器,各台算的是无重复子集,可以直接相加。

还是有问题,如果还是数量过大,炸了怎么办?

  • 方法 1:两阶段聚合(加随机前缀),效果:原来一个 Reducer 要扛 /hot 的全部流量,现在拆成几个 Reducer 平均扛,最后再简单相加即可。
    • 第一轮 Map 端:识别出超大的 key(比如 URL “/hot”),给它加随机后缀变成:/hot_0、/hot_1、/hot_2(几个就拆几份)。输出 (/hot_0, 1)、(/hot_1, 1) 等。第一轮 Reduce:各份独立统计,得到 (/hot_0, 5000)、(/hot_1, 5000) 等。
    • 第二轮 MapReduce:Map 读入这些部分结果,把后缀去掉,变回 (/hot, 5000)。Reduce 再把这几份相加,得到最终总数。
  • 方法 2:自定义分区函数 + 合并文件输出,MapReduce 本身不太好直接做这种动态拆分,但实际系统(如 Spark)可以更灵活地控制分区。:如果只是需要全部数据,不是聚合计数,比如要把所有 /hot 的日志记录导出来。做法识别到 /hot 太大,单独为它写一个分区规则:/hot 的记录,前 N 条发 Reducer 0,后 N 条发 Reducer 1,以此类推。每个 Reducer 输出时,把结果写到不同文件。最后手动把属于 /hot 的那几个文件合并(它们只是被切开了,内部有序,合并很快)。
  • 方法 3:Map 端 Combiner(局部聚合)如果只是 WordCount 这种场景,直接加 Combiner 就能极大缓解。Combiner 其实就是 Map 端的“迷你 Reduce”。每个 Map Task 在输出前,先把自己内存里相同的 key 合并一次,再发给 Reducer。这样即使总数据量很大,Reducer 接收的键值对数量也会大幅减少。例如:Map 端本地产出 100 万个 (/hot, 1),Combiner 一合并,只发一个 (/hot, 1000000) 给 Reducer,压力全消。
  • 方法 4:全局抽样 + 范围分区(避免哈希)如果我们需要全排序(比如分时间段输出),可以用范围分区代替哈希。先抽样,得到数据分布。按分布边界切出均匀的范围,让每个 Reducer 接收大致相等的一个 key 区间。这样也能避免哈希的随机性带来的倾斜(但超热 key 仍需额外处理)。

MapReduce Join数据
#

因为分析的需要,经常要把数据和外部表join,数据在mapreduce中如何join?

一、Reduce 端 JOIN:排序-合并 JOIN (Sort-Merge Join)
#

场景:两个大数据集都需要按相同 key 关联(例如订单表 order 和用户表 user,通过 user_id 关联)。

做法

  1. 两个数据集的 Mapper 分别读取自己的数据,都输出 (user_id, 记录) 作为键值对,并且给记录打上来源标签(比如 O:订单数据U:用户数据)。
  2. MapReduce 框架按 user_id 哈希分区并排序,把相同 user_id 的所有记录(不管是订单还是用户)都送到同一个 Reducer
  3. 因为经过了排序,同一个 user_id 的用户记录和该用户的所有订单记录在输入流中是连续相邻的,Reducer 就可以把它们收集起来做关联。

核心思想:框架的 Shuffle+Sort 把关联的记录“物以类聚”到一起,只需要在 Reducer 里像合并两个有序列表一样做连接。这就是“排序-合并 JOIN”。

优点:完全通用,几乎不依赖额外条件。
代价:所有数据都要经过 Shuffle 网络传输和磁盘排序,代价很大。

二、Map 端 JOIN:广播哈希 JOIN (Broadcast Hash Join)
#

如果其中一个数据集很小,能完全装入内存,就可以完全避免 Shuffle。

做法

  1. 把小表(比如用户表)整个加载到所有 Mapper 的内存里,构建成一个哈希表。
  2. 大表(订单表)的每个 Map 任务,读取自己的那份订单数据,每读一条订单,就从内存中的用户哈希表里查找对应的 user_id,直接关联输出结果。

核心思想:小表广播给所有工人,大表在 Map 端直接查表,全程不需要 Shuffle 和 Reducer。

优点:极其高效,没有网络传输和排序开销。
限制:必须有一个表足够小,能放进内存。

三、Map 端 JOIN 的变种:分区哈希 JOIN (Partitioned Hash Join)
#

如果两个表都很大,但它们刚好已经用相同的方式分好了区(比如 HDFS 上的两个目录,每个目录下的文件都是按 user_id 哈希取模分成了 R 份),那么可以只在 Map 端完成 JOIN。

做法

  1. 启动 R 个 Map 任务,每个任务只读取两个数据集中对应分区的那一份(比如 分区0 的订单文件和 分区0 的用户文件)。
  2. 每个 Map 任务把较小那个分区(比如分区0的用户数据)加载到内存构建哈希表。
  3. 然后遍历另一个数据的分区(订单分区0),逐条在内存哈希表中查找关联。

核心思想:既然数据已经按 key 物理上分了区,那就不需要再用 Shuffle 重新分区了,各区的 Map 任务在本地就能完成关联。

优点:避开了全量 Shuffle,只需做本地文件读取和内存哈希。
前提:两个数据集必须以相同方式(相同的分区数、相同的分区函数)预先分区好。

JOIN 类型发生在哪里如何保证同 key 相遇适用场景关键条件
排序-合并 JOIN (Reduce 端)Reducer通过 Shuffle 哈希分区 + 排序两个大表关联,无任何假设通用,但代价高昂
广播哈希 JOIN (Map 端)Mapper小表全量复制到每个 Mapper一个大表 + 一个小表小表必须能装入内存
分区哈希 JOIN (Map 端)Mapper依赖数据集本身的预先分区两个大表,且已用相同方式分区数据集必须预分区
合并分区 JOIN (通常指先分区再 Map 端连接)Map 或 Reducer先用一轮 MR 分区,再用 Map 端连接两个未分区的大表,想获得 Map 端效率需要两轮作业

Mapreduce的启发和超越mapreduce
#

抢占真的是用户需要的吗?

第11章 流处理系统
#

消息系统的缺失

  • 如果生产者发送消息的速度比消费者所能处理的快,会发生什么? 一般来说,有
    三种选择:系统丢弃消息:将消息缓存在队列中;或者激活背压, 也称为流量控
    制(即阻止生产者发送更多消息)。
  • 如果节点崩溃或者暂时 离 线 , 是否会有消息丢失? 与数据库一样,持久性可能需
    要写入磁盘和/或结合复制方案

多个消费者的消息传递模式
负载均衡式
每 一 条消息都只被传递给其中 一 个消费 者 ,所以悄 费者可 以共 享 主题中处理消
息的工作 。 代理可以任意分配消息给消费者。当处理消息的代价很高时-,此模
式非常有用,因此希望能够添加消费者来井行处理消息

扇出式
每条消息都被传递给所有的消费者。扇出允许几个独立的消费者各自“收听”相
同的消息广播,而不会相互影响,流相当于多个读取相同输入文件的不同批处
理作业

数据库和流

CDC

流如何join
#

如果表的内存太大怎么办? 解决方案:状态后端 + 分区 + RocksDB

  • 不用纯内存,用嵌入式磁盘引擎,流处理框架(如 Flink)的状态并不强制在 JVM 堆内存里。可以配置 RocksDB 作为状态后端。RocksDB 是一个嵌入式的、基于磁盘的 LSM 引擎,它能把你需要的数据存在本地 SSD 上,热数据仍在内存缓存。
    效果:内存里只放“索引+缓存”,全量数据在磁盘,单机能轻松处理几十 TB 状态。
  • 按 KEY 分区,状态随 KEY 分散到多台机器。这和 MapReduce 的哈希分区一模一样,例如按 user_id 哈希分区,用户表的状态和订单表的状态都被分到不同的 Flink 算子实例上。这里注意阿,在流处理中,所有需要关联的表,都以同一个 key(连接键)作为分区键。这个key就是join时候用到的key,简单来说就是一定要确保不同的表都是一个
    • 用户 ID 1~100 万 → 任务 1 的状态
    • 用户 ID 100 万~200 万 → 任务 2 的状态
      每个任务只负责自己那一份数据的状态,状态总量是集群总资源(内存+磁盘)的总和。
      面试加分点就在这里:你能说出 Keyed State + RocksDB + 哈希分区 的组合拳,证明你懂生产实践。
  • 如果是多个表,比方说大于2的数量的表join,确实无法找出同样的partion key的时候。先对着其中两个join,生成中间表,然后利用这个表和其它的表继续join
    假设你需要:订单 & 用户 & 商品 三表连接。
    • 第一步:订单连用户:订单流和用户 CDC 流都按 user_id 分区。完成后输出:(订单+用户信息),记为 OrderWithUser,此时这条结果里既有 user_id 也有 product_id。
    • 第二步:订单-用户结果连商品:上一步的输出 OrderWithUser 流,现在需要连商品表。为此,你需要重新按 product_id 分区。让 OrderWithUser 流和商品 CDC 流都按 product_id 分区,再做连接。
    • 关键:每次 JOIN 后,数据流的分区 key 可以改变,以适应下一次连接的 key。 这就是 Flink 的 keyBy 可以链式调用的原因。
  • 如何处理反查和反向索引,在表表连接中,用户表变更时需要快速找到“所有属于这个用户的订单”。实现上,可以在 order_state 上额外维护一个反向索引:
    • 索引:user_id -> List
    • 当用户表更新,引擎用这个索引瞬间定位到需要更新的订单,而不必全表扫描。
      这种索引同样存在于状态中,借助 RocksDB 可以高效支持。
流和梳Join
#

两个输入流都由活动事件组成,采用join操作用来搜索在特定时间窗口内发生的相关事件。例如,它可以 E 配相同用户在 30min 内采取的两个动作 。如果想要在一个流中查找相关事件 ,贝 lj 两个join输入可以是相同的流。

核心机制:

  • 窗口类型:滑动窗口、滚动窗口、会话窗口。
  • 水印:用来衡量“事件时间”进度的指标。系统说“此刻的水印是 10:00”,意味着“10:00 之前的正常数据应该都到齐了,再晚到的就是迟到数据”。
  • 迟到数据处理:可以选择丢弃、输出补偿结果,或允许更新之前已发出的结果。

典型题目:“实时计算广告曝光后 1 小时内的点击率,怎么设计?”

回答思路:

两条流按 ad_id 做流流连接。
设定一个 1 小时滚动窗口,以曝光事件的时间为基准,窗口覆盖 [t, t+1h]。
设置水印机制,声明“比当前水印迟到超过一定时间的数据直接丢弃”或“输出旁路数据用于后续修正”。
窗口触发计算:窗口内点击事件数 / 曝光事件数。
如果允许更新,对迟到数据可重新计算窗口结果并输出更新。
面试加分点:主动讨论水印和迟到数据的权衡(完整性 vs 延迟),说明你不仅会用工具,还理解实时计算的本质矛盾。

加分项回答:

水印与迟到数据的权衡:可以设置“允许迟到 15 秒”,迟到数据单独输出旁路流,用于事后对账或人工修正,而不是简单丢弃或无限等待。

窗口内聚合状态的优化:使用 Mini-Batch 合并输出,避免每来一条数据就触发下游更新,减轻下游存储压力。

去重:如果曝光流可能重复,要在窗口内保留唯一的曝光 ID 集合,确保计数准确。

大窗口下的状态膨胀:如果窗口很大(如 24h),可以考虑用时间切片,把大窗口切分成若干小窗口,定时合并,防止状态线性增长压爆内存。

流和表join
#

一个输入流由活动 事件组成,而另 一 个则是数据库变更日志。更新日志维护了数据库的本地最新副本。对于每个活动事件, join 操作用来查询数据库并输出 一 个包含更多信息的事件。本质:一条实时流,关联一个可变的表。在流处理框架里,这个表被加载到内存或本地状态中,供流事件到来时快速查询。

怎么实现:

  • 表加载:通过 CDC 把数据库表变成一个持续的变更流,流处理框架把这个流的所有变更“玩一遍”,在本地构建出一份完整的物化副本(状态存储)。
  • 连接:流事件到来时,按 key 查本地状态即可。
  • 一致性保证:流处理框架会保证“如果流事件和表的更新事件恰好在同一时刻,会先应用表更新,再处理流事件”,避免关联到过时的数据。

典型题目:“IoT 设备每秒发来传感器数据,只有 device_id。设备信息存在数据库里且会更新。要在流中实时补全设备信息,怎么做?”

回答思路:

用 CDC 把设备表变成变更流。
流处理引擎消费这个 CDC 流,在本地状态里维护一份设备信息表。
传感器数据流到来时,直接按 device_id 查本地状态,补全信息后输出。
当 CDC 流推来一条更新,系统自动更新本地状态。之后的传感器数据会自动匹配到新信息。
加分项:讨论 KeyedState(分区状态)和状态过期策略,防止状态无限增长。

加分项回答:

状态太大时用 RocksDB + 分区:设备表若达千万级,配置 RocksDB 状态后端,按 device_id 哈希分区到多个 Task,每个 Task 只负责一部分设备,内存加磁盘总容量支撑。

状态 TTL(生存时间):如果设备可能长期离线,可设置状态 TTL,让过期设备状态自动清除,避免僵尸数据堆积。

启动时状态恢复:从检查点或 Savepoint 恢复历史状态,避免重启时全量重拉 CDC 流(减少对源库冲击)。

连接一致性:使用 Flink 的 Input1 和 Input2 协同检查点,保证传感器事件和 CDC 更新事件在精确一次语义下有序处理,绝不用过时的设备信息。

表和表join
#

两个输入流都是数据库更新日志。在这种情况下, 一方的每一个变化都与另 一 方的最新状态相joino 结果是对两个表之间join 的物化视图进行持续的更新。本质:两个都被视作“动态表”,它们的 CDC 流分别输入流处理引擎,引擎在内部维护两个物化视图(状态),并持续更新连接结果。

核心机制:

状态管理:每个表按主键分区存储,更新一条记录时只操作对应分区的状态。

增量更新:订单表来了一条更新,不重新扫描整个用户表。只取出对应 user_id 的用户记录,更新连接结果中的那条记录,输出一个“更新”事件。

结果输出:最终输出是一个持续更新的流,类似一个实时更新的宽表。

典型题目:“业务库有多张表(订单、用户、商品),要对外提供一张实时更新的宽表,怎么设计?”

回答思路:

所有源表开启 CDC,变更流进 Kafka。

Flink 等流引擎同时消费所有表的 CDC 流。

在引擎内用状态存储维护每个表的物化视图。

按关联键做表表连接,输出宽表结果。

最终输出可以写回 Kafka、写入数据库或直接支持查询。

加分项:说明这是“流式数仓”的基本思想,核心是用事件流和物化视图替代批处理 ETL。

加分项回答:

反向索引加速更新:当用户表变更时,不用全扫订单状态。在订单状态上维护 user_id -> order_ids 的二级索引,实现 O(1) 查找并更新受影响订单。

多表多流对齐:多个 CDC 流有不同的时间线。可以使用 Flink 的 Async I/O 或 LookupTableSource 确保引用的是快照一致的版本,或者在连接时使用事件时间对齐。

结果输出策略:宽表结果频繁变化,直接写入 OLTP 数据库压力大。可以输出到 Kafka 并配合 Upsert Kafka Connector 或写入支持 Upsert 的数据库(如 ClickHouse、Doris),实现最终一致性视图。

状态隔离与资源规划:不同表的变更频率不同(订单高频,用户低频)。可将高频流的处理链与低频流隔离,避免互相影响,并设置不同的并行度和资源配额。

数据演进与 Schema 变更:源表可能加字段。设计时利用 Avro/Protobuf 的 schema 兼容性,让 CDC 流可以平滑演进,状态存储支持多版本。

面试题1(直接关联)
#

题目:Flink 中窗口连接 (Window Join) 如何保证不丢失也不重复?请说明水印和状态的作用。

解答
常规回答:水印指示窗口结束边界,当双方水印都超过窗口结束,触发连接结果,保证不丢。状态用于存储窗口期间双方的未完成数据。Exactly-Once 靠检查点保证。

进阶回答

  • 不丢失:水印等待乱序事件,直到水印超过窗口结束,才会触发计算。如果一方事件迟到,在 allowedLateness 内,窗口重新计算并输出更新结果。迟到过久的数据进 side output 保护。
  • 不重复:Flink 基于检查点实现精确一次。窗口连接的状态(包括双方记录)随检查点持久化。恢复时,从检查点读取状态和源偏移量,重新 replay。下游通过事务写入(如 Kafka 事务)提交,做到端到端 exactly-once。
  • 状态细节:窗口连接的状态是 JoinHolder,每个 key 存储左右两流的记录列表。当水印触发窗口评估,两个列表被 join,输出结果,然后清除该窗口的数据。状态 TTL 不用于窗口数据,而是确保异常数据不会残留。
  • 性能调优:状态过大可使用 RocksDB,但需要为 RocksDB 配备足够本地磁盘和内存;水印空闲等待 table.exec.source.idle-timeout 防止一方无数据导致水印停滞,另一方状态膨胀。
  • 总结:水印控制何时触发,状态保存中间结果,检查点保证故障后精确恢复。

面试题2(原理相关)
#

题目:解释流处理中“事件时间 vs 处理时间”的区别,为什么对于正确性至关重要?

解答
常规回答:事件时间是数据产生的时间,处理时间是系统处理数据的时间。按事件时间处理能正确反映业务时序,不受系统延迟影响。处理时间则无法处理延迟和乱序,结果不可复现。

进阶回答

  • 根本差异:事件时间嵌入在数据中,是业务事实的一部分;处理时间随系统负载、网络波动而变化。例如,用户在 10:00 点击,但日志在 10:05 才被处理,处理时间窗口就会将其归入 10:05 的统计,错误。
  • 对正确性的影响:金融风控需精确到事件发生的顺序,如果依赖处理时间,乱序会导致窗口聚合错误,漏报风险。对账单重跑结果不一致,无法审计。
  • 水印的引入:基于事件时间的窗口必须通过水印推测数据是否到齐。水印的选择是延迟和完整性的权衡——过早触发,错过迟到数据;过晚等待,输出延迟大。
  • 迟到处理:事件时间架构下,要能处理迟到数据,要么更新输出(可撤回模式),要么侧输出供补偿。保证了结果最终正确。
  • 实际对比:Flink 窗口使用事件时间时,即使作业重启重跑,只要源有历史数据,结果一致。而处理时间则取决于运行时速度。
  • 建议:永远选用事件时间,除非业务完全不关心顺序(如简单实时监控)。

面试题3(类似场景题,综合流连接)
#

题目:设计一个实时风控系统,需要关联交易流和用户历史行为画像(存储在数据库中,缓慢变化),计算聚合特征并判断风险,如何设计流处理管道?

解答
常规回答:交易流接入 Kafka,Flink 消费交易流,使用 Temporal Table Join 关联用户画像,然后进行窗口聚合,将特征向量输入模型,得出风险分,高风险告警写回 Kafka。

进阶回答

  1. :交易流 → Kafka Topic transactions;用户画像变更通过 CDC (Debezium) → Kafka → Flink 作为时态表。
  2. 关联:Flink SQL 定义一张版本化的用户画像表(使用 FOR SYSTEM_TIME AS OF 和交易的事件时间)。每笔交易都会匹配到当时的最新画像快照,保证关联正确性。
  3. 特征聚合SELECT user_id, TUMBLE_START(rowtime, INTERVAL '5' MINUTE) as win_start, COUNT(*) as tx_cnt, SUM(amount) as total_amt FROM enriched_transactions GROUP BY user_id, TUMBLE(rowtime, INTERVAL '5' MINUTE) 计算滑动窗口/滚动窗口内交易次数、金额等。
  4. 模型评估:聚合结果流通过 process 调用外部模型(可加载至内存或调用模型服务),得出风险分。若分数 > 阈值,产生告警事件写回 Kafka。
  5. Exactly-Once:源 Kafka 偏移、Flink 状态、结果 Kafka 事务,端到端精确一次。
  6. 状态管理:窗口聚合需大状态,用 RocksDB,设置 TTL 清理过期窗口。维表连接的状态由 Flink 管理,表版本清理策略是压缩到达一定数量。
  7. 监控与恢复:监控水印延迟、状态大小、checkpoint 时间。如果维表更新延迟(如 CDC 慢),可允许一定滞后,或切换为处理时间连接(弱一致,但更快)。
  8. 扩展性:事务按 user_id 哈希分区,保证同一用户数据顺序和聚合在同一 Task,避免 shuffle。
  9. 总结:综合运用时态表连接、窗口聚合、状态后端、精确一次语义,是流处理在风控中的典型落地。

结尾
#

唉,尴尬

狗头的赞赏码.jpg