第 42 章

从算法到系统设计

第四十二章:从算法到系统设计

你花了 40 章学会了数据结构与算法。现在面试官说:"设计一个类似 Twitter 的系统。"你的算法知识突然不知道往哪放了。

这是因为大多数人把"算法面试"和"系统设计面试"当成两个完全不同的领域。事实并非如此——系统设计的每一个核心决策,底层都有一个数据结构或算法在支撑。一致性哈希是哈希表的分布式版本,B+Tree 索引是二叉搜索树的工程优化,布隆过滤器是集合成员查询的概率版本,消息队列的优先级调度是堆的应用。

本章做四件事:第一,展示本书前面章节的算法如何在真实系统中体现;第二,介绍分布式场景下的新算法(Raft、CRDT、DHT);第三,与本站 Redis/Kafka/MySQL 书形成内链知识网络;第四,教你在系统设计面试中如何精确引用算法知识来加分。


Level 1 · 你需要知道的

1.1 算法在系统设计中的直接体现

每个系统设计决策的背后都有算法。下表列出最常见的映射关系:

系统组件 底层算法/数据结构 本书章节 工程场景
负载均衡 一致性哈希 第 8 章 Nginx、Memcached 节点分配
数据库索引 B+Tree、跳表 第 20、6 章 MySQL InnoDB、Redis ZSet
缓存淘汰 LRU/LFU(哈希+双链表) 第 4、8 章 Redis maxmemory-policy
去重/判存 布隆过滤器 第 9 章 爬虫 URL 去重、邮件反垃圾
搜索引擎 倒排索引(哈希+有序列表) 第 8、11 章 Elasticsearch
消息优先级 堆/优先队列 第 12 章 Kafka 优先级队列、任务调度
路由表 Trie 第 10 章 IP 路由最长前缀匹配
排行榜 跳表/红黑树 第 6、21 章 Redis ZRANGEBYSCORE
速率限制 滑动窗口/令牌桶 第 2 章 API Gateway 限流
分布式 ID 雪花算法(位运算) 第 31 章 Twitter Snowflake

一致性哈希(引用第 8 章)

场景: 你有 N 台缓存服务器,需要决定每个 key 存在哪台机器上。

朴素方案: server = hash(key) % N。问题:当增加或移除一台服务器时,几乎所有 key 都要重新映射——这意味着缓存几乎全部失效(cache stampede)。

一致性哈希解决了什么? 当节点增减时,平均只有 K/N 个 key 需要重新映射(K 是总 key 数,N 是节点数)。

import hashlib
from bisect import bisect_right

class ConsistentHash:
    """一致性哈希环实现
    
    核心思想:
    1. 将哈希值空间组织成一个环(0 ~ 2^32-1)
    2. 每个节点在环上占据多个位置(虚拟节点)
    3. 每个 key 顺时针找到最近的节点
    
    虚拟节点的作用(本书第 8 章详解):
    - 如果每个物理节点只有 1 个位置,负载分布极不均匀
    - 150-200 个虚拟节点时,标准差降到均值的 5-10%
    - 这是 Karger et al. (1997) 论文中的核心贡献
    """
    
    def __init__(self, nodes: list[str], replicas: int = 150):
        self.replicas = replicas
        self.ring = []          # (hash_value, node_name) 的有序列表
        self.hash_to_node = {}  # hash_value → node_name
        
        for node in nodes:
            self.add_node(node)
    
    def _hash(self, key: str) -> int:
        """使用 MD5 产生均匀分布的哈希值"""
        digest = hashlib.md5(key.encode()).hexdigest()
        return int(digest[:8], 16)  # 取前 32 bit
    
    def add_node(self, node: str):
        """添加物理节点 → 在环上添加 replicas 个虚拟节点"""
        for i in range(self.replicas):
            virtual_key = f"{node}#VN{i}"
            h = self._hash(virtual_key)
            self.ring.append(h)
            self.hash_to_node[h] = node
        self.ring.sort()
    
    def remove_node(self, node: str):
        """移除物理节点 → 移除其所有虚拟节点"""
        for i in range(self.replicas):
            virtual_key = f"{node}#VN{i}"
            h = self._hash(virtual_key)
            self.ring.remove(h)
            del self.hash_to_node[h]
    
    def get_node(self, key: str) -> str:
        """给定 key,找到负责的节点(顺时针第一个)
        
        算法:二分查找 → O(log(N * replicas))
        """
        if not self.ring:
            return None
        h = self._hash(key)
        # bisect_right 找到第一个 > h 的位置
        idx = bisect_right(self.ring, h) % len(self.ring)
        return self.hash_to_node[self.ring[idx]]


# 演示:节点增减时的 key 迁移量
def demo_migration():
    nodes = [f"server-{i}" for i in range(5)]
    ch = ConsistentHash(nodes)
    
    # 10000 个 key 的初始分配
    assignments_before = {}
    for i in range(10000):
        key = f"user:{i}"
        assignments_before[key] = ch.get_node(key)
    
    # 添加一个新节点
    ch.add_node("server-5")
    
    # 统计有多少 key 需要迁移
    migrated = 0
    for i in range(10000):
        key = f"user:{i}"
        if ch.get_node(key) != assignments_before[key]:
            migrated += 1
    
    # 理论值:10000 / 6 ≈ 1667(只有新节点负责的那部分需要迁移)
    print(f"迁移 key 数: {migrated} / 10000 ({migrated/100:.1f}%)")
    # 实际输出约 16-20%,接近理论值 1/6 ≈ 16.7%

布隆过滤器(引用第 9 章)

场景: 爬虫系统有 10 亿个已爬取的 URL,每次发现新 URL 需要判断是否已爬过。用 HashSet 存 10 亿个 URL 需要约 60GB 内存。布隆过滤器只需要约 1.2GB(以 1% 误判率计算)。

import mmh3  # MurmurHash3,非加密哈希,速度快
from bitarray import bitarray

class BloomFilter:
    """布隆过滤器实现
    
    数学基础(本书第 9 章详解):
    - m = -(n * ln(p)) / (ln(2))²  最优 bit 数
    - k = (m/n) * ln(2)            最优哈希函数数
    - 其中 n = 预期元素数,p = 目标误判率
    
    空间优势:HashSet 存 n 个元素需要 O(n * 元素大小)
    布隆过滤器只需 O(n) bits,与元素大小无关
    
    代价:
    - 有误判(False Positive):说"存在"可能是错的
    - 无漏判(No False Negative):说"不存在"一定是对的
    - 不支持删除(Counting Bloom Filter 可以,但空间翻倍)
    """
    
    def __init__(self, expected_items: int, fp_rate: float = 0.01):
        import math
        self.size = int(-(expected_items * math.log(fp_rate)) / (math.log(2) ** 2))
        self.num_hashes = int((self.size / expected_items) * math.log(2))
        self.bit_array = bitarray(self.size)
        self.bit_array.setall(0)
        
        # 空间计算示例:
        # n=10亿, p=1% → m≈95.8亿 bits ≈ 1.2GB, k=7
        # 对比 HashSet:每个 URL 平均 50 bytes → 50GB
    
    def add(self, item: str):
        """添加元素:对每个哈希函数,将对应位设为 1"""
        for i in range(self.num_hashes):
            idx = mmh3.hash(item, i) % self.size
            self.bit_array[idx] = 1
    
    def contains(self, item: str) -> bool:
        """查询元素:所有哈希位都为 1 → 可能存在;任一为 0 → 一定不存在"""
        for i in range(self.num_hashes):
            idx = mmh3.hash(item, i) % self.size
            if not self.bit_array[idx]:
                return False  # 一定不存在
        return True  # 可能存在(有 fp_rate 概率误判)


# 在系统设计中如何使用
class WebCrawler:
    """带布隆过滤器的爬虫去重"""
    
    def __init__(self, expected_urls: int = 1_000_000_000):
        self.bloom = BloomFilter(expected_urls, fp_rate=0.001)
        # 1% 误判意味着 1000 万个 URL 可能被错误跳过
        # 对爬虫来说可以接受:下次爬取周期会补上
    
    def should_crawl(self, url: str) -> bool:
        """决定是否爬取该 URL"""
        if self.bloom.contains(url):
            return False  # 可能已爬过(或误判),跳过
        return True
    
    def mark_crawled(self, url: str):
        """标记 URL 为已爬取"""
        self.bloom.add(url)

B+Tree 索引(引用第 20 章)

场景: MySQL 执行 SELECT * FROM users WHERE age BETWEEN 25 AND 35,如何高效找到所有满足条件的行?

class BPlusTreeNode:
    """B+Tree 节点 — 简化实现展示核心思想
    
    为什么数据库选择 B+Tree 而非红黑树/AVL?
    1. 磁盘友好:每个节点是一个磁盘页(通常 16KB),减少 I/O 次数
    2. 范围查询高效:叶子节点形成有序链表,范围查询只需顺序扫描
    3. 高扇出(fanout):内部节点只存 key,不存数据,能容纳更多 key
       → 树高极低(3-4 层即可索引数十亿行)
    
    与二叉搜索树的对比:
    - BST:高度 O(log₂ n) → 10 亿行需要 30 次比较 → 30 次磁盘 I/O
    - B+Tree(阶=1000):高度 O(log₁₀₀₀ n) → 10 亿行只需 3 次 I/O!
    """
    
    def __init__(self, is_leaf: bool = False, order: int = 4):
        self.is_leaf = is_leaf
        self.keys = []        # 排序的 key 列表
        self.children = []    # 内部节点:子节点指针;叶子节点:数据/记录指针
        self.next_leaf = None  # 叶子节点的链表指针(用于范围查询)
        self.order = order    # B+Tree 的阶(实际中通常 100-1000)


def range_query_explanation():
    """B+Tree 范围查询流程(概念说明)
    
    SQL: SELECT * FROM users WHERE age BETWEEN 25 AND 35
    
    步骤:
    1. 从根节点出发,用二分查找定位 age=25 所在的叶子节点  → O(log_B n) 次 I/O
    2. 在叶子节点中找到第一个 >= 25 的位置
    3. 沿叶子链表顺序扫描,直到 age > 35 停止           → O(结果集大小 / 页大小) 次 I/O
    
    关键优势:
    - 定位开销极低(3-4 次 I/O)
    - 范围扫描是顺序 I/O(SSD 顺序读 3GB/s vs 随机读 100MB/s)
    """
    pass

1.2 从单机算法到分布式算法

当你的系统从单机扩展到多机,单机上的"确定性"消失了:

单机假设 分布式现实
操作是原子的 网络可能丢包、延迟、乱序
时钟统一 每台机器的时钟有偏差(时钟漂移)
不会故障 任何节点随时可能宕机
共享内存 数据必须通过网络传输

这些变化催生了一系列分布式算法。以下是你在系统设计面试中最可能遇到的三个:

Raft 共识算法(简要介绍):

class RaftState:
    """Raft 共识算法的状态机模型
    
    解决的问题:多个节点如何对"日志的顺序"达成一致?
    
    学术来源:Ongaro & Ousterhout, "In Search of an Understandable Consensus Algorithm"
    (USENIX ATC 2014) — 专门设计为 Paxos 的可理解版本
    
    核心角色:
    - Leader:接收客户端请求,复制日志到 Follower
    - Follower:被动接收日志
    - Candidate:在选举期间竞争成为 Leader
    
    关键不变量:
    - 每个 term 最多一个 Leader
    - Leader 的日志一定包含所有已提交的条目
    - 多数派同意即可提交
    """
    
    FOLLOWER = "follower"
    CANDIDATE = "candidate"
    LEADER = "leader"
    
    def __init__(self, node_id: int, total_nodes: int):
        self.node_id = node_id
        self.state = self.FOLLOWER
        self.current_term = 0
        self.voted_for = None
        self.log = []  # [(term, command), ...]
        self.commit_index = 0
        
        # Leader 特有状态
        self.next_index = {}   # 对每个 follower,下一个要发送的日志索引
        self.match_index = {}  # 对每个 follower,已确认复制的最高日志索引
        
        self.majority = total_nodes // 2 + 1
    
    def request_vote(self, candidate_term: int, candidate_id: int,
                     last_log_index: int, last_log_term: int) -> bool:
        """投票 RPC:决定是否给候选人投票
        
        投票条件:
        1. candidate_term >= 自己的 term
        2. 本 term 还没投过票(或者已投给该候选人)
        3. 候选人的日志至少和自己一样新(先比 term,再比 index)
        """
        if candidate_term < self.current_term:
            return False
        
        if candidate_term > self.current_term:
            self.current_term = candidate_term
            self.state = self.FOLLOWER
            self.voted_for = None
        
        if self.voted_for is not None and self.voted_for != candidate_id:
            return False
        
        # 日志新鲜度比较(关键!保证 Leader Completeness)
        my_last_term = self.log[-1][0] if self.log else 0
        my_last_index = len(self.log)
        
        log_is_up_to_date = (
            candidate_term > my_last_term or
            (candidate_term == my_last_term and last_log_index >= my_last_index)
        )
        
        if log_is_up_to_date:
            self.voted_for = candidate_id
            return True
        return False
    
    def append_entries(self, leader_term: int, entries: list,
                       prev_log_index: int, prev_log_term: int,
                       leader_commit: int) -> bool:
        """日志追加 RPC:Leader 向 Follower 复制日志
        
        Follower 检查:
        1. Leader 的 term >= 自己的 term
        2. 在 prev_log_index 位置,term 必须匹配
           → 不匹配说明日志有分歧,需要回退
        """
        if leader_term < self.current_term:
            return False
        
        self.current_term = leader_term
        self.state = self.FOLLOWER
        
        # 一致性检查
        if prev_log_index > 0:
            if len(self.log) < prev_log_index:
                return False  # 日志太短
            if self.log[prev_log_index - 1][0] != prev_log_term:
                # 日志冲突:删除该位置及之后的所有条目
                self.log = self.log[:prev_log_index - 1]
                return False
        
        # 追加新条目
        self.log.extend(entries)
        
        # 更新 commit_index
        if leader_commit > self.commit_index:
            self.commit_index = min(leader_commit, len(self.log))
        
        return True

1.3 系统设计面试中的算法引用模板

面试时如何精确引用算法知识:

# 面试官:"如何设计一个分布式缓存系统?"
# 
# 你的回答框架:
SYSTEM_DESIGN_WITH_ALGORITHMS = """
1. 数据分片(Sharding)
   → 使用一致性哈希(Karger et al., 1997)
   → 每个物理节点 150 个虚拟节点保证均匀分布
   → 节点增减时只需迁移 1/N 的数据

2. 缓存淘汰
   → LRU 策略:哈希表 + 双向链表,O(1) get/put
   → 如果需要考虑频率:LFU 或 W-TinyLFU(Caffeine 的实现)

3. 热点检测
   → Count-Min Sketch(概率数据结构,第 9 章)
   → 用 O(1) 空间估计每个 key 的访问频率

4. 缓存穿透防护
   → 布隆过滤器判断 key 是否可能存在于数据库中
   → 不存在的 key 直接返回,不查数据库

5. 一致性保证
   → 如果要求强一致:Raft 复制日志
   → 如果允许最终一致:gossip 协议 + 向量时钟
"""

1.4 常见错误

错误 1:混淆"算法"和"数据结构"的应用层次

# ❌ 错误:在系统设计中说"用红黑树做索引"
# 红黑树是内存数据结构,磁盘上应该用 B+Tree

# ❌ 错误:在系统设计中说"用 DFS 做服务发现"
# DFS 是图遍历算法,服务发现应该用 gossip 或注册中心

# ✅ 正确的映射:
CORRECT_MAPPINGS = {
    "内存索引": "红黑树、跳表(Redis)",
    "磁盘索引": "B+Tree、LSM-Tree",
    "内存缓存淘汰": "LRU(哈希+双链表)",
    "分布式路由": "一致性哈希",
    "近似计数": "HyperLogLog",
    "近似判存": "布隆过滤器",
    "分布式共识": "Raft / Paxos",
}

错误 2:过度设计小规模系统

当面试官说"用户量 10 万"时,你不需要一致性哈希——单机 Redis 就够了。算法和数据结构的选择必须匹配规模


Level 2 · 它是怎么运行的

2.1 分布式算法深度解析

Raft 的工程实现细节

Raft 被 etcd(Kubernetes 的底层存储)、CockroachDB、TiKV(TiDB 的存储引擎)等系统采用。下面分析其核心机制:

选举机制的数学保证:

import random

class RaftElection:
    """Raft 选举的关键设计决策
    
    为什么使用随机超时?
    - 如果所有节点同时开始选举,可能永远无法选出 Leader(活锁)
    - 随机超时 [150ms, 300ms] 确保某个节点大概率先超时发起选举
    - 数学分析:如果超时范围远大于网络 RTT,冲突概率趋近于 0
    
    超时范围的选择(Ongaro 论文建议):
    - 下界 > 平均网络 RTT(避免频繁选举)
    - 上界 < 可接受的不可用时间
    - 范围 = 上界 - 下界 >> 平均 RTT(减少冲突)
    """
    
    def __init__(self, heartbeat_interval: int = 50):
        self.heartbeat_interval = heartbeat_interval  # ms
        # 选举超时 = [2*heartbeat, 4*heartbeat] — 经验值
        self.election_timeout_range = (
            heartbeat_interval * 3,   # 150ms
            heartbeat_interval * 6    # 300ms
        )
    
    def random_election_timeout(self) -> int:
        """返回随机选举超时时间(ms)"""
        return random.randint(*self.election_timeout_range)
    
    def simulate_election(self, num_nodes: int = 5, 
                          network_delay_ms: int = 5) -> dict:
        """模拟一次选举过程
        
        最佳情况:1 RTT 完成选举(150 + 5 = 155ms)
        最坏情况:多次冲突(极少见,期望 < 2 次)
        """
        # 每个节点的超时时间
        timeouts = [self.random_election_timeout() for _ in range(num_nodes)]
        
        # 第一个超时的节点发起选举
        first_candidate = min(range(num_nodes), key=lambda i: timeouts[i])
        election_start = timeouts[first_candidate]
        
        # 投票到达时间 = 选举开始 + 网络延迟
        vote_collection_time = election_start + network_delay_ms
        
        # 如果没有其他节点同时超时(间隔 > RTT),选举成功
        second_timeout = sorted(timeouts)[1]
        conflict = (second_timeout - election_start) < network_delay_ms
        
        return {
            "winner": first_candidate,
            "time_ms": vote_collection_time,
            "conflict": conflict,
            "rounds": 2 if conflict else 1,
        }

CRDT:无冲突复制数据类型

class GCounter:
    """G-Counter(Grow-only Counter)— 最简单的 CRDT
    
    学术来源:Shapiro et al., "A comprehensive study of Convergent and 
    Commutative Replicated Data Types" (INRIA Technical Report, 2011)
    
    核心思想:
    - 每个节点维护自己的计数器
    - 全局值 = 所有节点计数器之和
    - 合并操作 = 取每个节点计数器的 max
    
    为什么需要 CRDT?
    - 传统分布式系统:要么用锁(性能差),要么用共识(延迟高)
    - CRDT:无锁、无共识、最终一致 — 适用于计数器、集合等简单数据类型
    - 代价:只能支持特定操作(单调递增/递减/添加/删除),不能支持任意操作
    """
    
    def __init__(self, node_id: str, num_nodes: int):
        self.node_id = node_id
        self.counters = {}  # node_id → count
    
    def increment(self, amount: int = 1):
        """本地递增"""
        self.counters[self.node_id] = self.counters.get(self.node_id, 0) + amount
    
    def value(self) -> int:
        """全局值 = 所有节点计数之和"""
        return sum(self.counters.values())
    
    def merge(self, other: 'GCounter'):
        """合并操作:取每个节点的 max
        
        关键性质(为什么不会冲突):
        - 交换律:merge(A, B) == merge(B, A)
        - 结合律:merge(merge(A, B), C) == merge(A, merge(B, C))
        - 幂等性:merge(A, A) == A
        
        这三个性质保证了无论消息以什么顺序到达,最终状态一致
        """
        for node_id, count in other.counters.items():
            self.counters[node_id] = max(
                self.counters.get(node_id, 0),
                count
            )


class PNCounter:
    """PN-Counter(支持递增和递减)
    
    思路:用两个 G-Counter,一个记录增,一个记录减
    value = P.value() - N.value()
    """
    
    def __init__(self, node_id: str, num_nodes: int):
        self.p = GCounter(node_id, num_nodes)  # positive
        self.n = GCounter(node_id, num_nodes)  # negative
    
    def increment(self, amount: int = 1):
        self.p.increment(amount)
    
    def decrement(self, amount: int = 1):
        self.n.increment(amount)
    
    def value(self) -> int:
        return self.p.value() - self.n.value()
    
    def merge(self, other: 'PNCounter'):
        self.p.merge(other.p)
        self.n.merge(other.n)


class LWWRegister:
    """Last-Writer-Wins Register — 基于时间戳的 CRDT
    
    应用场景:分布式数据库中单个字段的并发更新
    - 多个节点同时修改同一字段 → 保留时间戳最大的那个
    
    局限性:
    - 依赖时钟同步(NTP 精度通常 1-10ms)
    - 如果两次写入间隔 < 时钟误差,结果不确定
    - 解决方案:Hybrid Logical Clock (HLC) — Kulkarni et al. (2014)
    """
    
    def __init__(self, node_id: str):
        self.node_id = node_id
        self.value = None
        self.timestamp = 0  # Lamport timestamp 或物理时间
    
    def write(self, value, timestamp: int):
        if timestamp > self.timestamp:
            self.value = value
            self.timestamp = timestamp
    
    def merge(self, other: 'LWWRegister'):
        if other.timestamp > self.timestamp:
            self.value = other.value
            self.timestamp = other.timestamp

分布式哈希表(DHT)

class KademliaNode:
    """Kademlia DHT — P2P 网络中的分布式哈希表
    
    学术来源:Maymounkov & Mazières, "Kademlia: A Peer-to-peer Information 
    System Based on the XOR Metric" (IPTPS 2002)
    
    被 BitTorrent、IPFS、Ethereum 等系统采用
    
    核心思想:
    1. 每个节点有一个 160-bit ID
    2. 距离 = XOR(异或)— 满足三角不等式,但不需要物理距离
    3. 每个节点维护 160 个"桶"(k-bucket),第 i 个桶存储距离在 [2^i, 2^(i+1)) 的节点
    4. 查找操作:每步将距离减半 → O(log n) 步找到目标
    
    与一致性哈希的对比:
    - 一致性哈希:需要中心化的"哈希环"信息 → 适合数据中心内部
    - DHT:完全去中心化,每个节点只需知道 O(log n) 个其他节点 → 适合 P2P
    """
    
    def __init__(self, node_id: int, k: int = 20):
        self.node_id = node_id
        self.k = k  # 每个桶的最大容量
        self.buckets = [[] for _ in range(160)]  # 160 个 k-bucket
        self.data = {}  # 本节点存储的 key-value
    
    def distance(self, id1: int, id2: int) -> int:
        """XOR 距离"""
        return id1 ^ id2
    
    def bucket_index(self, other_id: int) -> int:
        """确定 other_id 属于哪个桶(即 XOR 距离的最高位位置)"""
        dist = self.distance(self.node_id, other_id)
        if dist == 0:
            return 0
        return dist.bit_length() - 1
    
    def find_closest_nodes(self, target_id: int, count: int = 20) -> list:
        """找到路由表中距离 target_id 最近的 count 个节点
        
        这是 Kademlia 查找的核心操作:
        每次调用,返回的节点都比上一次更接近目标
        → 保证 O(log n) 步收敛
        """
        all_nodes = []
        for bucket in self.buckets:
            all_nodes.extend(bucket)
        
        # 按 XOR 距离排序
        all_nodes.sort(key=lambda n: self.distance(n, target_id))
        return all_nodes[:count]
    
    def iterative_find_value(self, key: int) -> str:
        """迭代查找过程(简化)
        
        实际步骤:
        1. 从自己的路由表中找 α 个最近节点
        2. 并行发送 FIND_VALUE RPC
        3. 如果返回值 → 结束
        4. 如果返回更近的节点 → 更新候选列表,继续查找
        5. 重复直到找到值或候选列表不再更新
        
        复杂度:O(log n) 轮 RPC,每轮 α 个并行请求
        在 100 万节点的网络中,约 20 轮 × α=3 = 60 次 RPC 即可找到任何 key
        """
        pass

2.2 算法与系统组件的深层对应

LSM-Tree vs B+Tree:写优化 vs 读优化

class LSMTreeConcept:
    """LSM-Tree (Log-Structured Merge Tree) 概念说明
    
    学术来源:O'Neil et al., "The Log-Structured Merge-Tree (LSM-Tree)" (1996)
    
    被 LevelDB、RocksDB、Cassandra、HBase 采用
    
    核心思想:
    1. 写入先进内存(MemTable,通常是跳表或红黑树)
    2. MemTable 满后刷到磁盘成为 SSTable(Sorted String Table)
    3. 后台周期性合并(Compaction)多个 SSTable
    
    与 B+Tree 的对比:
    ┌──────────┬───────────────────┬───────────────────┐
    │          │ B+Tree            │ LSM-Tree          │
    ├──────────┼───────────────────┼───────────────────┤
    │ 写放大   │ 高(随机写磁盘)    │ 低(顺序写)      │
    │ 读放大   │ 低(1次定位)      │ 高(可能查多层)   │
    │ 空间放大  │ 低               │ 中(多版本共存)    │
    │ 适用场景  │ 读多写少(MySQL) │ 写多读少(日志系统)│
    └──────────┴───────────────────┴───────────────────┘
    """
    
    def __init__(self, memtable_size: int = 4 * 1024 * 1024):  # 4MB
        self.memtable = {}  # 模拟跳表/红黑树
        self.memtable_size = memtable_size
        self.sstables = []  # 磁盘上的有序文件列表,新的在前
    
    def put(self, key: str, value: str):
        """写入:先写 WAL(略),再写 MemTable
        
        时间复杂度:O(log n) 写入跳表
        磁盘 I/O:只有 WAL 的顺序追加写(1 次)
        """
        self.memtable[key] = value
        if len(self.memtable) >= self.memtable_size:
            self._flush()
    
    def get(self, key: str) -> str:
        """读取:先查 MemTable → 再从新到旧查 SSTable
        
        最坏情况:需要查所有层级 → 用布隆过滤器跳过不包含该 key 的 SSTable
        """
        if key in self.memtable:
            return self.memtable[key]
        
        for sstable in self.sstables:  # 从新到旧
            # 实际实现中:先查布隆过滤器,再二分查找
            if key in sstable:
                return sstable[key]
        
        return None  # key 不存在
    
    def _flush(self):
        """将 MemTable 刷到磁盘成为新的 SSTable"""
        sstable = dict(sorted(self.memtable.items()))  # 排序
        self.sstables.insert(0, sstable)  # 新的在前
        self.memtable = {}
    
    def _compact(self):
        """合并多个 SSTable(简化版 — 实际有 Leveled/Sized-Tiered 策略)
        
        为什么需要 Compaction?
        1. 减少读放大(层数减少)
        2. 回收已删除/覆盖的数据空间
        3. 保持有序性
        
        代价:写放大(数据被读出再写入),但都是顺序 I/O
        """
        if len(self.sstables) >= 4:  # 简单触发条件
            merged = {}
            for sstable in reversed(self.sstables):  # 旧的先合,新的覆盖
                merged.update(sstable)
            self.sstables = [dict(sorted(merged.items()))]

跳表在 Redis 中的应用

import random

class SkipListNode:
    def __init__(self, key: float, value: str, level: int):
        self.key = key
        self.value = value
        self.forward = [None] * (level + 1)  # 每层的 next 指针
        self.span = [0] * (level + 1)        # 每层跨越的节点数(用于 rank)

class SkipList:
    """跳表 — Redis ZSET 的底层实现
    
    为什么 Redis 选择跳表而非红黑树?(antirez 本人的解释)
    1. 实现简单:跳表 100 行代码 vs 红黑树 300+ 行
    2. 范围操作自然高效:ZRANGEBYSCORE 只需找到起点,然后顺序遍历
    3. 内存开销可控:平均每个节点 1.33 个指针(p=1/4 时)
    4. 与红黑树性能相当:O(log n) 查找/插入/删除
    
    在系统设计面试中的说法:
    "Redis 的排行榜功能用跳表实现。跳表的期望高度是 O(log n),
    查找和范围查询都是 O(log n + k)。相比红黑树,跳表的优势在于
    范围查询不需要中序遍历,直接沿底层链表顺序扫描。"
    """
    
    MAX_LEVEL = 32
    P = 0.25  # 晋升概率(Redis 使用 1/4)
    
    def __init__(self):
        self.header = SkipListNode(float('-inf'), '', self.MAX_LEVEL)
        self.level = 0
        self.length = 0
    
    def _random_level(self) -> int:
        """随机决定新节点的层数
        
        几何分布:P(level=k) = p^(k-1) * (1-p)
        期望层数 = 1/(1-p) = 1.33(当 p=0.25)
        """
        level = 0
        while random.random() < self.P and level < self.MAX_LEVEL:
            level += 1
        return level
    
    def insert(self, key: float, value: str):
        """插入操作 — O(log n) 期望时间
        
        步骤:
        1. 从最高层开始,找到每层的插入位置(update 数组)
        2. 随机生成层数
        3. 在每层插入节点
        """
        update = [None] * (self.MAX_LEVEL + 1)
        current = self.header
        
        # 从高层到低层查找插入位置
        for i in range(self.level, -1, -1):
            while current.forward[i] and current.forward[i].key < key:
                current = current.forward[i]
            update[i] = current
        
        # 生成随机层数
        new_level = self._random_level()
        if new_level > self.level:
            for i in range(self.level + 1, new_level + 1):
                update[i] = self.header
            self.level = new_level
        
        # 创建新节点并插入每层
        new_node = SkipListNode(key, value, new_level)
        for i in range(new_level + 1):
            new_node.forward[i] = update[i].forward[i]
            update[i].forward[i] = new_node
        
        self.length += 1
    
    def range_by_score(self, min_score: float, max_score: float) -> list:
        """范围查询 — O(log n + k),k 为结果集大小
        
        这就是 Redis ZRANGEBYSCORE 的核心逻辑
        """
        # 找到第一个 >= min_score 的节点
        current = self.header
        for i in range(self.level, -1, -1):
            while current.forward[i] and current.forward[i].key < min_score:
                current = current.forward[i]
        
        current = current.forward[0]  # 移到第一个 >= min_score 的节点
        
        # 沿底层链表扫描
        result = []
        while current and current.key <= max_score:
            result.append((current.key, current.value))
            current = current.forward[0]
        
        return result

2.3 与本站其他书的知识串联

本站(dev.yiteai.com)的技术书籍形成了一个知识网络。以下是本章内容与其他书的关联:

本章算法/概念 关联书籍 关联章节 具体联系
一致性哈希 《Redis 深度》 集群分片 Redis Cluster 用 16384 个 slot 做哈希分片
布隆过滤器 《Redis 深度》 数据结构 Redis 4.0 起内置 BF.ADD / BF.EXISTS
B+Tree 《高性能 MySQL》 索引章节 InnoDB 聚簇索引的存储结构
LSM-Tree 《高性能 MySQL》 MyRocks RocksDB 存储引擎的核心
优先队列/堆 《Kafka 深度》 消息调度 延迟消息、优先级队列实现
Raft 共识 《Redis 深度》 Sentinel/Cluster Redis Sentinel 使用类 Raft 的领导选举
CRDT 《Redis 深度》 Active-Active Redis Enterprise 的冲突解决
# 示例:Redis 中的布隆过滤器使用
"""
# Redis 命令(对应本章 BloomFilter 类的操作)
BF.RESERVE crawler_urls 0.001 1000000000  # 创建:10亿容量,0.1%误判率
BF.ADD crawler_urls "https://example.com/page1"  # 添加
BF.EXISTS crawler_urls "https://example.com/page1"  # 查询 → 1 (可能存在)
BF.EXISTS crawler_urls "https://example.com/unknown" # 查询 → 0 (一定不存在)

# 内存占用:约 1.44GB(对比 SET 存 10 亿 URL ≈ 60GB)
"""

# 示例:MySQL 索引查询对应的算法
"""
-- SQL 查询
EXPLAIN SELECT * FROM orders WHERE user_id = 12345 AND created_at > '2024-01-01';

-- 底层 B+Tree 操作(对应本章 BPlusTreeNode):
-- 1. 在 (user_id, created_at) 联合索引的 B+Tree 上
-- 2. 定位 user_id=12345 的第一个叶子节点   → O(log_B n) 次 I/O
-- 3. 在叶子链表上扫描 created_at > '2024-01-01' → O(k/B) 次 I/O
-- 4. 回表(如果不是覆盖索引)读取完整行

-- 这就是为什么联合索引的列顺序很重要:
-- (user_id, created_at) → 先定位 user,再范围扫描 time ✓
-- (created_at, user_id) → 扫描所有 2024 年数据再过滤 user ✗
"""

2.4 消息队列中的数据结构

class DelayQueue:
    """延迟消息队列 — 基于最小堆
    
    应用场景:
    - 订单 30 分钟未支付自动取消
    - 定时推送通知
    - 重试退避(1s, 2s, 4s, 8s...)
    
    实现思路:
    - 最小堆按"到期时间"排序
    - 消费者循环检查堆顶:如果到期则取出执行
    
    在 Kafka 中的实际做法(本站 Kafka 书详解):
    - Kafka 本身不支持延迟消息
    - 常见方案:多个延迟 topic(delay_1s, delay_5s, delay_30m...)
    - 或者用时间轮(Hierarchical Timing Wheel)实现
    """
    
    import heapq
    import time
    
    def __init__(self):
        self.heap = []  # (execute_at_timestamp, message_id, message)
        self.counter = 0  # 用于打破时间戳相同时的比较
    
    def put(self, message: str, delay_seconds: float):
        """添加延迟消息"""
        import time
        execute_at = time.time() + delay_seconds
        import heapq
        heapq.heappush(self.heap, (execute_at, self.counter, message))
        self.counter += 1
    
    def poll(self) -> str:
        """取出已到期的消息(如果有)"""
        import time, heapq
        if not self.heap:
            return None
        if self.heap[0][0] <= time.time():
            _, _, message = heapq.heappop(self.heap)
            return message
        return None  # 最早的消息还没到期


class TimingWheel:
    """时间轮 — Kafka/Netty 的定时器实现
    
    学术来源:Varghese & Lauck, "Hashed and Hierarchical Timing Wheels" (1996)
    
    为什么不用堆?
    - 堆的插入和删除是 O(log n)
    - 时间轮的插入和删除是 O(1)
    - 当有数百万个定时任务时,差异巨大
    
    核心思想(类比时钟):
    - 秒针:60 格,每格 1 秒
    - 分针:60 格,每格 1 分钟
    - 时针:24 格,每格 1 小时
    
    任务按过期时间放入对应格子,"指针"每 tick 前进一格
    当高层格子到达时,任务下沉到低层格子(类似时钟进位的逆过程)
    """
    
    def __init__(self, tick_ms: int = 1, wheel_size: int = 60):
        self.tick_ms = tick_ms
        self.wheel_size = wheel_size
        self.buckets = [[] for _ in range(wheel_size)]
        self.current_tick = 0
        self.overflow_wheel = None  # 更高层的时间轮(延迟加载)
    
    def add_task(self, task_id: str, delay_ticks: int):
        """添加定时任务 — O(1)"""
        if delay_ticks < self.wheel_size:
            # 放入当前层
            bucket_idx = (self.current_tick + delay_ticks) % self.wheel_size
            self.buckets[bucket_idx].append((task_id, delay_ticks))
        else:
            # 溢出到更高层
            if self.overflow_wheel is None:
                self.overflow_wheel = TimingWheel(
                    self.tick_ms * self.wheel_size,
                    self.wheel_size
                )
            self.overflow_wheel.add_task(task_id, delay_ticks // self.wheel_size)
    
    def advance(self):
        """前进一个 tick — O(当前格子中的任务数)"""
        self.current_tick = (self.current_tick + 1) % self.wheel_size
        expired_tasks = self.buckets[self.current_tick]
        self.buckets[self.current_tick] = []
        return expired_tasks

Level 3 · 规范怎么定义的

3.1 CAP 定理与算法选择

CAP 定理(Brewer, 2000; Gilbert & Lynch 证明, 2002)指出分布式系统不可能同时满足三个性质:

由于网络分区不可避免(P 必须选),实际选择是 CP vs AP

CAP_ALGORITHM_MAPPING = {
    "CP (一致性优先)": {
        "算法": "Raft / Paxos / ZAB",
        "特征": "网络分区时少数派停止服务",
        "系统": ["etcd", "ZooKeeper", "CockroachDB", "Spanner"],
        "适用场景": "金融交易、分布式锁、配置管理",
        "本书关联": "Raft 实现(本章 Level 1)",
    },
    "AP (可用性优先)": {
        "算法": "Gossip / CRDT / Last-Writer-Wins",
        "特征": "网络分区时所有节点继续服务,但数据可能不一致",
        "系统": ["Cassandra", "DynamoDB", "Redis Cluster (部分场景)"],
        "适用场景": "社交媒体、购物车、DNS",
        "本书关联": "CRDT 实现(本章 Level 2)",
    },
}

PACELC 扩展(Abadi, 2012):在没有分区(Partition-free)时,还有 Latency vs Consistency 的权衡:

系统 分区时 正常时
Spanner CP LC(延迟换一致性)
DynamoDB AP LC/EC(可配置)
Cassandra AP EC(最终一致)

3.2 系统设计面试的评分维度

根据 Meta 系统设计面试官的公开分享(2023 年 InfoQ 演讲)和 Designing Data-Intensive Applications(Kleppmann, 2017)的框架:

评分维度 权重 与算法的关联
需求分析 15% 估算 QPS/存储量 → 决定用什么复杂度的算法
High-Level 设计 25% 组件选择 → 背后是数据结构的选择
Deep Dive 35% 细节追问 → 需要解释底层算法原理
权衡讨论 25% Trade-off 分析 → 算法复杂度的空间/时间权衡

Deep Dive 中的算法引用示例:

# 面试官:"排行榜如何实时更新?"
# 
# 你的回答:
"""
使用 Redis 的 ZSET(Sorted Set)。

底层实现是跳表 + 哈希表的组合:
- 跳表:维护分数排序,支持 O(log n) 的范围查询
- 哈希表:O(1) 的单 key 查找

操作复杂度:
- ZADD(更新分数):O(log n)
- ZRANK(查排名):O(log n) — 通过 span 累加实现
- ZRANGEBYSCORE(取 top-k):O(log n + k)

为什么不用 MySQL ORDER BY?
- 每次查询需要全表排序或索引扫描
- 高并发下(如直播打赏排行),MySQL 承受不住
- Redis 跳表的更新是原子的,天然支持并发

容量估算:
- 1000 万用户排行榜 → ZSET 大小约 200MB
- 如果排行榜按游戏分区,每个分区 10 万人 → 单个 ZSET 约 2MB
"""

3.3 分布式系统中的经典算法定理

定理/算法 提出者 年份 核心内容 面试引用场景
FLP 不可能性 Fischer, Lynch, Paterson 1985 异步系统中即使只有一个节点故障,也不可能保证共识 解释为什么需要超时机制
CAP 定理 Brewer (Gilbert & Lynch 证明) 2000/2002 CP vs AP 的选择 所有系统设计题的权衡基础
Paxos Lamport 1989/1998 第一个被证明正确的共识算法 背景知识,解释为什么 Raft 更实用
Raft Ongaro & Ousterhout 2014 可理解的共识算法 需要强一致性的场景
Gossip Demers et al. 1987 流行病式传播,最终一致 AP 系统的传播机制
Vector Clock Lamport / Fidge 1978/1988 判断事件因果关系 解决并发冲突
Consistent Hashing Karger et al. 1997 最小化重映射 分布式缓存/存储分片

3.4 一致性模型谱系

从强到弱的一致性模型,及其对应的实现算法:

CONSISTENCY_SPECTRUM = {
    "Linearizability(线性一致性)": {
        "保证": "操作看起来像在单个时间点原子执行",
        "算法": "Raft/Paxos + 读写都走 Leader",
        "代价": "延迟高(每次写需要多数派确认)",
        "系统": "Spanner, CockroachDB",
    },
    "Sequential Consistency(顺序一致性)": {
        "保证": "所有操作有一个全局顺序,每个进程的操作保持原始顺序",
        "算法": "全序广播(Total Order Broadcast)",
        "代价": "中等延迟",
        "系统": "ZooKeeper (写操作)",
    },
    "Causal Consistency(因果一致性)": {
        "保证": "因果相关的操作按顺序可见,并发操作可以乱序",
        "算法": "向量时钟 / 版本向量",
        "代价": "较低延迟,允许部分并发",
        "系统": "MongoDB (w:majority + causal sessions)",
    },
    "Eventual Consistency(最终一致性)": {
        "保证": "如果没有新写入,最终所有副本收敛到相同值",
        "算法": "Gossip + CRDT / Last-Writer-Wins",
        "代价": "最低延迟,但可能读到旧数据",
        "系统": "DynamoDB, Cassandra, DNS",
    },
}

3.5 系统设计中的"数量级估算"方法

这是系统设计面试的必备技能——它直接决定你选择什么算法和数据结构:

# Jeff Dean 的"每个程序员都应该知道的延迟数字"(2012,持续更新)
LATENCY_NUMBERS = {
    "L1 cache reference":           0.5,    # ns
    "L2 cache reference":           7,      # ns
    "Main memory reference":        100,    # ns
    "SSD random read":              150_000, # ns = 150 µs
    "HDD random read":              10_000_000, # ns = 10 ms
    "Network round trip (同机房)":   500_000, # ns = 0.5 ms
    "Network round trip (跨城市)":   50_000_000, # ns = 50 ms
}

def estimate_scale(description: str) -> dict:
    """系统设计的容量估算模板
    
    面试中边算边说:
    'DAU 1000 万,每人每天 10 次请求 → QPS = 10M * 10 / 86400 ≈ 1200'
    '峰值按 3 倍估算 → 3600 QPS → 单机 Redis 轻松搞定'
    """
    # Twitter-like 系统的典型估算
    estimation = {
        "DAU": 10_000_000,
        "每人每天操作": 10,
        "总日请求": 100_000_000,
        "平均 QPS": 100_000_000 / 86400,  # ≈ 1157
        "峰值 QPS": 1157 * 3,  # ≈ 3472
        "每条推文大小": 280,  # bytes (纯文本)
        "每天新推文": 10_000_000,  # 假设 10% 用户发推
        "每天数据增量": 10_000_000 * 280,  # ≈ 2.8 GB
        "一年数据": 2.8 * 365,  # ≈ 1 TB
    }
    
    # 基于估算选择技术方案
    if estimation["峰值 QPS"] < 10000:
        estimation["缓存方案"] = "单机 Redis 足够(10万+ QPS)"
    elif estimation["峰值 QPS"] < 100000:
        estimation["缓存方案"] = "Redis Cluster(3-5 节点)"
    else:
        estimation["缓存方案"] = "多级缓存(本地缓存 + Redis Cluster)"
    
    return estimation

Level 4 · 边界与陷阱

4.1 系统设计面试中的常见算法错误

错误 1:选错数据结构的"层次"

# ❌ "用红黑树做数据库索引"
# 问题:红黑树是内存数据结构,每个节点一次 I/O
# 100 万行数据,树高 20 → 20 次随机磁盘 I/O → 200ms(假设 HDD)
# 
# ✅ "用 B+Tree 做数据库索引"
# B+Tree 阶=1000,100 万行数据,树高 2 → 2 次 I/O → 20ms

# ❌ "用 B+Tree 做内存数据库索引"
# 问题:B+Tree 为磁盘优化(大节点减少 I/O),内存中没有 I/O 开销
# 内存中 B+Tree 的 cache line 利用率反而不如红黑树/跳表
# 
# ✅ "用跳表做内存索引"(Redis 的选择)
# 或 "用 ART(Adaptive Radix Tree)做内存索引"(现代内存数据库的选择)

错误 2:混淆"精确"和"近似"的场景

# 场景:统计网站 UV(独立访客数)
# 
# ❌ "用 HashSet 存所有用户 ID"
# 问题:10 亿用户 × 8 bytes/ID = 8GB 内存
# 
# ✅ 分析:UV 需要精确值吗?
# - 如果用于计费(广告计费)→ 需要精确 → 用 bitmap + 分片
# - 如果用于数据分析仪表盘 → 接受 2% 误差 → HyperLogLog(12KB 内存!)

# Redis 中的 HyperLogLog
"""
PFADD page_views:2024-01-01 "user:12345"   # 添加
PFADD page_views:2024-01-01 "user:67890"   # 添加
PFCOUNT page_views:2024-01-01              # 获取 UV 估计值
# 标准误差 0.81%,内存固定 12KB,与基数大小无关!
"""

错误 3:忽略"热点问题"

# 场景:一致性哈希分布缓存
# 
# 问题:某个明星发了一条微博,数亿人访问 → 该 key 所在节点过载
# 一致性哈希无法解决热点(它只保证均匀分布,不保证均匀访问)
# 
# 解决方案:
HOT_KEY_SOLUTIONS = {
    "本地缓存": "热 key 识别后,在应用服务器本地缓存(TTL 短)",
    "Key 分散": "对热 key 加后缀(key_1, key_2, ...key_N),分散到 N 个节点",
    "读写分离": "热 key 专门用只读副本承接",
}

# 如何识别热 key?
# → Count-Min Sketch(概率数据结构,本书第 9 章)
# → 在网关层用 O(1) 空间估计每个 key 的访问频率

错误 4:在 AP 系统中追求强一致性

# 面试官:"设计一个分布式购物车"
# 
# ❌ "用 Raft 保证购物车数据强一致"
# 问题:
# - 购物车不需要强一致(用户加了商品但暂时看不到,比服务不可用好)
# - Raft 在网络分区时少数派不可用 → 用户无法操作购物车
# 
# ✅ "用 CRDT(或 LWW)保证最终一致,AP 优先"
# - 网络分区时所有节点继续服务
# - 分区恢复后自动合并(CRDT 的冲突解决)
# - 极端情况下可能出现"删了又出现"(可接受,用户重新删除即可)

4.2 系统设计面试中如何"展示"算法知识

策略 1:自然引入,不生硬

# ❌ 生硬引入:
# "这里我要用一致性哈希。一致性哈希是 Karger 1997 年提出的..."
# (面试官:我知道什么是一致性哈希,你直接说怎么用)

# ✅ 自然引入:
# "数据分片用一致性哈希,每个物理节点 150 个虚拟节点。
#  这样节点增减时只需迁移约 1/N 的数据,避免缓存雪崩。
#  如果面试官追问,我可以解释虚拟节点为什么是 150 个。"

策略 2:用算法解释设计决策

# 面试官:"为什么选 Redis 的 ZSET 而不是 MySQL 排序?"
# 
# 好的回答:
"""
两个原因,都和底层数据结构有关:

1. 写入性能:
   - ZSET 底层是跳表,更新分数 O(log n),纯内存操作
   - MySQL 更新索引需要 B+Tree 的节点分裂,涉及磁盘 I/O
   - 在 QPS 10000+ 的排行榜场景,ZSET 延迟 < 1ms,MySQL 可能 > 10ms

2. 范围查询:
   - ZRANGEBYSCORE 沿跳表底层链表顺序扫描,O(log n + k)
   - MySQL 的 ORDER BY LIMIT 如果 offset 大,需要跳过前面的行
   - 深翻页时 ZSET 用 ZRANGEBYSCORE 的 cursor 模式更高效
"""

策略 3:主动提出 trade-off

# 面试官:"如何实现全局唯一 ID?"
# 
# 展示多种方案及其算法背景:
UNIQUE_ID_TRADEOFFS = {
    "UUID v4": {
        "算法": "128-bit 随机数",
        "优点": "无需协调,完全去中心化",
        "缺点": "无序(B+Tree 索引频繁分裂)、128-bit 太长",
        "适用": "不需要有序性的场景",
    },
    "Snowflake": {
        "算法": "时间戳(41bit) + 机器ID(10bit) + 序列号(12bit) = 64bit",
        "优点": "趋势递增(B+Tree 友好)、64-bit 紧凑",
        "缺点": "依赖时钟(时钟回拨会重复)",
        "适用": "大多数互联网场景",
    },
    "数据库自增": {
        "算法": "中心化计数器",
        "优点": "严格递增、简单",
        "缺点": "单点瓶颈、扩展性差",
        "适用": "小规模系统、订单号",
    },
}

4.3 从算法面试到系统设计面试的思维转换

算法面试思维 系统设计面试思维
追求最优解 追求"足够好"的解 + 清晰的 trade-off
关注单机性能 关注分布式扩展性
正确性优先 可用性、一致性、分区容忍性的权衡
精确答案 数量级估算
代码实现 架构图 + 组件选型
边界条件 故障模式(节点宕机、网络分区、磁盘满)
# 算法思维 → 系统设计思维的转换示例
# 
# 算法题:"找到数组中出现次数最多的元素" → Counter + 排序
# 
# 系统设计版:"实时统计全站最热门的 100 个搜索词"
# 
# 需要考虑的算法/数据结构:
# - Count-Min Sketch:O(1) 空间估计词频
# - Min-Heap (size=100):维护 Top-100
# - 时间窗口:滑动窗口算法的分布式版本
# - 数据分区:按哈希分片到多台机器
# - 合并:Map-Reduce 思想,每台机器出 Top-100,再全局排序

4.4 真实系统案例:算法如何影响架构决策

案例 1:Google Bigtable 的布隆过滤器

"""
Bigtable (Chang et al., 2006) 的读路径:

1. 客户端请求读取某个 row key
2. 先查 MemTable(内存) — O(log n) 跳表查找
3. 如果不在 MemTable,需要查磁盘上的多个 SSTable
4. 每个 SSTable 有一个布隆过滤器:
   - BF 说"不在" → 跳过这个 SSTable(节省一次磁盘 I/O)
   - BF 说"可能在" → 读取该 SSTable
5. 实验数据:布隆过滤器将读操作的磁盘访问次数减少了 10x

算法选择的影响:
- 没有布隆过滤器:每次读需要查 N 个 SSTable → N 次随机 I/O
- 有布隆过滤器:期望只查 1 个 SSTable → 1 次随机 I/O
- 代价:每个 SSTable 额外存储 ~10 bits/key 的布隆过滤器
"""

案例 2:Kafka 的时间轮

"""
Kafka 需要管理数百万个延迟操作(超时请求、延迟 ACK 等)。

朴素方案:用优先队列(堆)
- 插入 O(log n),删除最小 O(log n)
- n = 百万时,每次操作 20 次比较 → CPU 开销大

Kafka 的方案:层次时间轮(Hierarchical Timing Wheel)
- 插入 O(1),到期检查 O(1)
- 通过溢出到高层轮处理长延迟

性能对比(Kafka 官方 benchmark):
- 堆实现:100 万定时任务,吞吐约 50 万次/秒
- 时间轮:100 万定时任务,吞吐约 500 万次/秒(10x 提升)
"""

案例 3:Redis Cluster 的哈希槽 vs 一致性哈希

"""
Redis Cluster 没有用传统一致性哈希环,而是用了"哈希槽"(Hash Slot):
- 固定 16384 个 slot
- CRC16(key) % 16384 → 得到 slot 编号
- 每个节点负责一部分 slot

为什么不用一致性哈希环?(antirez 的解释)
1. 哈希槽更容易管理:移动一个 slot = 移动一组 key
2. 配置信息更紧凑:16384 bit = 2KB 就能描述整个集群的 slot 分配
3. 一致性哈希的虚拟节点管理复杂

这是工程取舍的典型案例:
- 学术上一致性哈希更优雅
- 工程上哈希槽更简单、更可控
- 两者性能差异在 Redis 规模下可以忽略
"""

4.5 面试中的"系统设计 + 算法"融合题

近年来顶级公司出现了"融合题"——在系统设计面试中要求写算法代码:

# 融合题示例:"设计一个限流器(Rate Limiter)"
# 
# 需要同时展示:
# 1. 系统设计:部署位置、分布式方案、容错
# 2. 算法实现:限流算法的精确实现

class SlidingWindowRateLimiter:
    """滑动窗口限流器
    
    系统设计考量:
    - 部署在 API Gateway 层
    - 用 Redis 存储窗口数据(多实例共享)
    - 时间窗口粒度选择:1秒 vs 1分钟 vs 自适应
    
    算法考量:
    - 固定窗口:简单但有边界突刺问题
    - 滑动日志:精确但内存大(存每个请求时间戳)
    - 滑动窗口计数器:折中方案(本实现)
    """
    
    def __init__(self, max_requests: int, window_seconds: int):
        self.max_requests = max_requests
        self.window_seconds = window_seconds
        self.requests = {}  # user_id → [(timestamp, count), ...]
    
    def allow_request(self, user_id: str, current_time: float) -> bool:
        """判断是否允许请求
        
        滑动窗口计数器算法:
        - 将时间划分为子窗口(如 1 秒一个)
        - 统计当前窗口内的请求总数
        - 用当前子窗口的位置做加权(处理窗口边界)
        """
        window_start = current_time - self.window_seconds
        
        if user_id not in self.requests:
            self.requests[user_id] = []
        
        # 清理过期记录
        self.requests[user_id] = [
            (ts, count) for ts, count in self.requests[user_id]
            if ts > window_start
        ]
        
        # 统计窗口内请求数
        total = sum(count for _, count in self.requests[user_id])
        
        if total >= self.max_requests:
            return False
        
        # 记录当前请求
        # 实际 Redis 实现:INCR + EXPIRE 的组合
        current_bucket = int(current_time)  # 按秒分桶
        for i, (ts, count) in enumerate(self.requests[user_id]):
            if int(ts) == current_bucket:
                self.requests[user_id][i] = (ts, count + 1)
                return True
        
        self.requests[user_id].append((current_time, 1))
        return True


class TokenBucket:
    """令牌桶算法 — 另一种限流实现
    
    与滑动窗口的对比:
    - 令牌桶允许突发流量(桶满时可以瞬间消耗)
    - 滑动窗口严格限制窗口内总量
    
    适用场景:
    - API 限流(允许短暂突发)→ 令牌桶
    - 防刷(严格限制频率)→ 滑动窗口
    """
    
    def __init__(self, rate: float, capacity: int):
        self.rate = rate        # 令牌填充速率(个/秒)
        self.capacity = capacity  # 桶容量
        self.tokens = capacity  # 当前令牌数
        self.last_refill_time = 0
    
    def allow_request(self, current_time: float) -> bool:
        """尝试消耗一个令牌"""
        # 补充令牌
        elapsed = current_time - self.last_refill_time
        self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
        self.last_refill_time = current_time
        
        # 消耗令牌
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        return False

本章小结

核心要点 说明
算法是系统的基石 每个系统组件底层都有数据结构/算法支撑
一致性哈希 分布式缓存分片,节点增减只迁移 1/N 数据
布隆过滤器 用 1/50 内存实现"可能存在"判定,减少磁盘 I/O
B+Tree vs LSM 读优化 vs 写优化,匹配不同业务场景
Raft CP 系统的共识基础,保证日志一致
CRDT AP 系统的冲突解决,无锁无共识
面试展示 自然引入算法,解释设计决策,主动讨论 trade-off

下一章预告: 第 43 章提供完整的学习路线和刷题指南,从入门到竞赛级别的进阶路径。

本章评分
4.7  / 5  (3 评分)

💬 留言讨论