本页定位 · 数据库进阶

《数据库三剑客》讲清楚了 关系型数据库怎么用。本页补的是"单机关系型装不下时的那一整层":缓存、搜索引擎、分布式事务、分库分表。这些是系统从"能跑"走到"扛得住流量、保得住一致"的必经之路。我会像《Java 进阶》那样,把每个知识点拆成"它是什么 → 为什么这样设计 → 怎么用 → 踩过什么坑"。基线:Redis 7、Elasticsearch 8、MySQL 8 分库分表实践。

1

为什么关系型之外还需要别的存储

Polyglot Persistence · 多语言持久化

"一个 MySQL 走天下"在中小项目完全够用。但当数据量、并发、查询形态变得复杂,单一存储会同时被读写放大、热点、慢查询拖垮。于是出现"按访问形态选存储"的思路——本章先把视野打开。

一个 MySQL 走天下的天花板在哪

关系型库强在强一致事务 + 复杂关联查询,但它的代价也很清楚:行式存储、按主键/B树查、写要落盘还要保证 WAL。当下面任意一件事发生,单机 MySQL 就开始吃力:① 单表几千万行,B+树变深、查询变慢;② 读 QPS 上万、热点行被反复读,CPU 和连接被打满;③ 要跑 LIKE '%关键词%' 这种全表扫描的全文检索;④ 数据结构天天变,ALTER TABLE 要锁表。这些需求不是 MySQL 不努力,而是它的设计目标本来就不覆盖它们。

论为什么不能"硬扛"

很多人第一反应是"加机器、加索引、加从库"。但本质是用错了工具:让关系库去扛它不擅长的读放大/全文/灵活 schema,只会让主库越来越慢、运维越来越痛。换工具往往比加机器更划算。

多语言持久化:按访问形态选存储

"Polyglot Persistence" 是 Martin Fowler 提的概念:不同数据有不同的访问形态,就该用不同存储。订单要强一致 → 关系库;商品详情要扛读 → 缓存;搜索框要分词检索 → 搜索引擎;关系网络 → 图库。它们之间用应用层编排 + 异步同步串起来,而不是塞进一个库。

# 一个"下单"动作背后的存储分工(应用层编排) order = mysql.insert(Order) # 源:强一致 redis.set("cart:%d" % uid, ...) # 读加速 mq.publish("order.created", order) # 异步:同步 ES/扣库存/算积分 # 各存储各司其职,最终一致由消费端保证
# 读路径:优先缓存,缓存未命中回源并回填(典型 Cache-Aside) def get_product(pid): v = redis.get("product:" + pid) if v is None: v = mysql.get_product(pid) # 回源 redis.set("product:" + pid, v, ttl=300) # 回填 return v

选型矩阵:谁擅长什么、谁不擅长什么

下面这张矩阵是后面所有章节的"地图"。看的时候记住:没有万能存储,只有"在这个维度上强"。

存储形态擅长典型代表不擅长
关系型强一致事务、复杂关联查询MySQL / PostgreSQL超大规模水平扩展、全文检索
缓存 KV亚毫秒读取、扛热点Redis复杂查询、持久化可靠性偏弱
文档库半结构化、灵活 schemaMongoDB跨文档强事务、复杂 join
搜索引擎全文检索、聚合分析Elasticsearch强一致事务
列族海量写入、宽表Cassandra / HBase随机读、事务
图多跳关系查询Neo4j通用 CRUD
时序带时间戳指标InfluxDB事务、点查

一致性、延迟、成本的三角不可能

分布式存储有个隐藏约束:一致性(Consistency)、可用性(Availability)、分区容忍(Partition Tolerance)三者最多取其二(CAP)。网络分区不可避免,所以真实取舍是 CP(保一致、牺牲部分可用) 还是 AP(保可用、接受最终一致)。选存储前先想清楚:这条数据"几秒不一致会不会出大事"?

论选型的"三连问"

① 数据形状?结构固定还是多变?② 一致性要求?差几秒能不能忍?③ 读多写多、还是算多?问完,矩阵里基本只剩 1-2 个候选。别一上来就追"最潮"的数据库。

两个最高频的误区

把缓存当数据库 / 把 ES 当源

误区一:缓存丢数据也无所谓。Redis 宕机或驱逐后,如果源库没同步好,用户看到"下单成功但查不到"——这就是把缓存当源的下场。误区二:把 ES 当主库。ES 近实时、不保证强一致、更新要整文档重索引,订单状态只存 ES 迟早出对账事故。铁律:强一致数据,关系库为源;缓存与 ES 都是副本。

本章落地的第一件小事

不用立刻推翻架构。先在你现有系统里标出三类数据:① 必须强一致的(账户、订单);② 读多写少可缓存的(商品详情、配置);③ 需要检索/分析的(日志、搜索)。这一步"分类"本身就是多语言持久化的起点。

引入新存储前,先算一笔成本账

新存储不是免费午餐。每引入一种,就要承担:机器与容量成本、监控告警、备份恢复、故障演练,以及团队学习曲线。小团队尤其要克制——一个运维良好、加好索引的 PostgreSQL,常常能扛住你以为"必须上 NoSQL"的量级。

隐性成本说明
运维部署、升级、容量规划、故障处理
监控每种存储一套指标与告警
一致性多副本同步、对账、幂等开发量
人力团队要懂多种数据库的坑

论先优化,后加库

慢查询先加索引、大表先冷热归档、读多写少先上从库。这些零成本优化常能再撑一年。只有当瓶颈真实、优化无效,才引入对应专门存储,且要有明确 owner 与降级方案。别为了"架构看起来先进"而加库。

2

Redis 深入:它远不止是缓存

Redis 7 · 持久化 · 三大坑 · 分布式锁 · 集群

很多人把 Redis 当"缓存"用,但它是内存数据结构服务——字符串、哈希、列表、集合、有序集合(zset)、流(Stream)、位图都能直接用,对应不同业务语义。这一章把生产里最容易翻车的几块讲透。

先认全它的数据结构

Redis 的"快"除了因为内存,还因为每种结构都对应一个真实业务语义,不用自己拼。下面这张表是选型的基础。

结构典型用途坑点
String缓存、计数器、分布式锁大 value 阻塞
Hash对象字段级读写(用户资料)别存超大字段
List简单队列、最新 N 条不是可靠队列
ZSet排行榜、延迟队列、限流窗口score 精度
Stream消息流、事件溯源消费组概念
Bitmap / HLL签到、UV 估算HLL 是近似值

持久化:内存数据怎么不丢

Redis 数据在内存,重启就丢?不会——它有持久化。RDB 是定时全量快照(恢复快、文件小,但可能丢最近一段);AOF 记录每条写命令(数据更全,但文件大、恢复慢);混合持久化(Redis 4+ 默认推荐)用 RDB 做基础 + AOF 增量日志,重启时先加载 RDB 再回放增量,兼顾速度与完整性。

# redis.conf 关键配置 save 900 1 # 900 秒内至少 1 次写,触发 RDB appendonly yes # 开启 AOF appendfsync everysec # 每秒刷盘:丢最多 1s,性能可接受(默认) aof-use-rdb-preamble yes # 混合:AOF 文件头部用 RDB 格式
# 运维常用:手动触发 AOF 重写、查看持久化状态 BGREWRITEAOF # AOF 过大时压缩(fork 子进程,不阻塞) redis-cli info persistence # 看 RDB/AOF 最后保存时间、是否重写中 # 重写期间内存会短暂翻倍(copy-on-write),注意预留内存

论appendfsync 三档怎么选

always 每写都刷盘,最安全但最慢;everysec 每秒刷,丢最多 1 秒,生产默认;no 交给 OS,最快但可能丢很多。别为了"绝对不丢"上 always——除非你能接受吞吐腰斩。

缓存三大坑之一:穿透

穿透=查一个根本不存在的 key(如 id=-1),缓存没有、DB 也没有,请求每次都打到 DB,恶意刷接口时 DB 直接被打穿。解法两招:① 缓存空值(查不到也写个短 TTL 的 null,注意用布隆或特殊标记区分"真没有");② 布隆过滤器前置拦截——不存在的 key 在过滤器层就挡掉,根本不进 DB。

# 布隆过滤器思路(伪代码) if not bloom.might_contain(key): return None # 一定不存在,直接挡 val = redis.get(key) if val is None: val = db.query(key) redis.set(key, val or "NULL", ttl=300) # 空值也缓存

缓存三大坑之二三:击穿与雪崩

击穿:某个热点 key 过期的瞬间,海量并发同时发现缓存没了,一起打到 DB——一个 key 拖垮一片。雪崩:大量 key 同一时刻过期(或 Redis 整体宕机),DB 被瞬间冲垮。两者解法不同:击穿用互斥锁重建(只有一个线程去查 DB,其余等待)或逻辑过期(值里带过期时间,后台异步刷新,读永远不等待);雪崩用过期时间加随机抖动 + 集群高可用(Redis 挂了还有从/分片兜底)。

# 互斥锁重建(只放一个线程去查 DB) val = redis.get(key) if val is None: if redis.set("lock:"+key, 1, nx=True, px=3000): val = db.query(key) redis.set(key, val, ttl=300) redis.delete("lock:"+key) else: time.sleep(0.05); return get(key) # 稍等重试 # 雪崩防护:ttl 加随机抖动 ttl = base + random.randint(0, 300)
逻辑过期 vs 真实过期

"逻辑过期"是缓存 key 永不真过期,value 里塞一个 expire 字段,读时若发现过期就触发异步重建。好处是读永远不卡;坏处是重建完成前返回的是旧数据。适合"数据稍微旧一点没关系"的场景(商品详情),不适合"余额必须实时"的场景。

坑本质典型解法
穿透查不存在的 key布隆过滤器 / 空值缓存
击穿热点 key 过期瞬间并发互斥锁重建 / 逻辑过期
雪崩大量 key 同刻失效/宕机过期加抖动 / 集群高可用

分布式锁:SET NX / Lua / Redlock

用 Redis 做分布式锁,核心是 SET key value NX PX ttl:只有一个客户端能抢到(NX=不存在才设),并带唯一 value 和过期时间,避免"业务没跑完锁先过期"或"误删别人的锁"。释放锁必须用 Lua 脚本保证"判断+删除"原子,否则可能删掉别人刚抢到的锁。

# 抢锁:唯一 token 防误删,PX 防死锁 SET lock:order:1001 "uuid-abc" NX PX 30000 # 释放锁:只有 value 是自己才删(Lua 原子执行) if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) end
别用单实例 Redis 当生产锁

主从异步复制时,主挂了锁还没同步到从,从升主后另一个客户端能再抢到同一把锁——锁丢了。要强一致用 Redlock(多节点 majority 抢锁,但有争议、实现复杂)或干脆 ZooKeeper / etcd(基于租约,更稳)。多数业务"最终一致 + 幂等"比"强一致锁"更划算,别为了锁而锁。

集群与高可用:主从 / 哨兵 / Cluster

单机 Redis 有容量上限(内存)和单点风险。主从复制:从库异步复制主库,做读写分离/备份;哨兵(Sentinel):监控主库,挂了自动选从库顶上(故障转移);Cluster:把数据按 16384 个槽位分片到多节点,支撑大数据量 + 高吞吐,节点间 gossip 互通。三者常组合:Cluster 内部每分片仍是主从 + 哨兵。

# 搭建 3 主 3 从的 Cluster(示意) redis-cli --cluster create \ 10.0.0.1:6379 10.0.0.2:6379 10.0.0.3:6379 \ 10.0.0.4:6379 10.0.0.5:6379 10.0.0.6:6379 \ --cluster-replicas 1

论内存淘汰:满了怎么办

Redis 达到 maxmemory 后按策略淘汰:noeviction(写报错,默认最稳)、allkeys-lru(全体 LRU,缓存常用)、volatile-lru(只淘汰带过期的)、allkeys-lfu(按访问频率)。做缓存选 allkeys-lru/allkeys-lfu;做存储(如会话)选 noeviction 并加告警,否则数据会被默默清掉。

大 key 与热 key:单线程的隐形炸弹

Redis 是单线程处理命令,一个慢命令会阻塞全实例。两颗雷:大 key(如存了百万元素的 Hash/List)删除或序列化极慢;热 key(某 key 被海量请求打)让单节点成为瓶颈。先定位再治理。

# 扫描大 key(生产用 --bigkeys,别用 KEYS *) redis-cli --bigkeys # 热 key:本地缓存 + 多副本打散 for i in range(replicas): val = redis_slave[i].get(key) # 读分散到多个副本
别用 KEYS * 查线上

KEYS * 会遍历全库、阻塞单线程,线上一敲可能卡死服务。排查用 --bigkeys 或 SCAN 游标迭代。大 key 拆分(如按段 hash 成多个小 key),热 key 加本地缓存 + 多副本,别让一个 key 拖垮整机。

3

Elasticsearch:倒排索引与检索

Elasticsearch 8 · Inverted Index · 分词 · 聚合

关系型库的 LIKE '%关键词%' 是全表扫描 + 不支持分词,百万级就慢。ES 用倒排索引:先分词,再建"词 → 文档"的映射,检索时直接定位。这一章讲清它的原理、写入与"该当副本不该当源"。

倒排索引原理:把大海捞针变查字典

正排索引是"文档 → 内容"(一行一行存),查词要扫全部。倒排索引反过来:词 → 哪些文档包含它。比如文档 1 有"苹果手机"、文档 2 有"苹果电脑",分词后得到 term 字典:苹果 → [1,2]、手机 → [1]、电脑 → [2]。用户输入"苹果",直接命中文档 1、2,不用扫全文。

Term文档列表 (docId)词频
苹果1, 2各 1
手机11
电脑21

论为什么搜索快

检索"苹果" = 在 term 字典里二分/哈希查到 posting list [1,2],O(log n)。再做相关度(TF-IDF / BM25)排序。这比"逐行 LIKE"快几个数量级,且天然支持高亮、聚合。

分词(Analysis):中文最容易被坑

分词把"iPhone 16 钛金属"拆成 [iphone, 16, 钛, 金属] 这些 term。ES 的分析链是 character filter → tokenizer → token filter。英文可以按空格切,中文必须用语义分词器(如 IK、jieba),否则默认会把每个字当 term,"苹""果"分开,搜"苹果"搜不到语义整体。映射里要显式指定 analyzer。

# 建索引时指定中文分词器(IK) PUT /products { "mappings": { "properties": { "title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" } } } } # 验证分词效果 GET /products/_analyze { "text": "iPhone 16 钛金属手机" }
分词器不一致 = 搜不到

索引时用 ik_max_word(切得细),搜索时也用细的才能匹配;若搜索用 ik_smart(切得粗)反而更准(避免过召回)。索引 analyzer 与搜索 analyzer 要配套设计,否则"建了索引却搜不出"是中文 ES 最常见投诉。

写入流程与近实时(NRT)

ES 写入不是"写进去立刻能搜到"。流程:写请求进 内存 buffer → 刷到 translog(WAL,防丢)→ 默认 1 秒一次 refresh 把 buffer 变成可被搜索的段(segment) → 段多了后台 merge 合并 → 默认 30 分钟或 translog 太大时 flush 落盘。这就是"近实时":写入到可搜有约 1 秒延迟。

# 强制刷新(测试/导入后想立刻可搜时用,生产别滥用) POST /products/_refresh # 批量导入建议关 refresh 提速,导完再开 PUT /products/_settings { "refresh_interval": "-1" } # 导入完成恢复 PUT /products/_settings { "refresh_interval": "1s" }

聚合分析(Aggregation)

ES 不只是搜,还能边搜边统计:terms 分组、avg/sum 度量、date_histogram 按时间桶。这让它成为日志分析(ELK)的主力——Kibana 的图背后全是 aggs。

# 搜"手机"并按品牌分组计数,再算均价 GET /products/_search { "query": { "match": { "title": "手机" } }, "aggs": { "by_brand": { "terms": { "field": "brand", "size": 10 } }, "avg_price": { "avg": { "field": "price" } } }, "size": 0 }

论聚合跑到慢?多半是"深分页"或"宽字段"

terms 聚合的 size 太大、或对高基数字段(如 user_id)做 terms 聚合,会产生海量桶、吃内存。ES 7+ 默认有 search.max_buckets 限制。需要全量分组时,考虑用 composite 聚合做游标分页,而不是一次拉几万桶。

# 按天统计销量(date_histogram 时间桶) GET /orders/_search { "aggs": { "per_day": { "date_histogram": { "field":"created", "calendar_interval":"day" }, "aggs": { "sum_amt": { "sum": { "field":"amount" } } } } }, "size": 0 } # 深分页:from/size 过万会吃内存,改用 search_after 游标 GET /orders/_search { "search_after": [last_sort_val], "size": 1000 }

应作为只读副本:CDC 同步

正确姿势是关系库是源,ES 是只读检索副本。源数据变更时,通过 CDC(Change Data Capture)把 binlog/oplog 实时喂给 ES:Canal(MySQL)、Debezium(多源)、或应用双写。这样 ES 挂了不影响下单,ES 数据延迟几秒也能接受。

# 应用层双写(简单但易不一致,需幂等兜底) def update_product(p): mysql.update(p) # 源 mq.publish("product.changed", p) # 异步同步 ES # 更稳:Canal 监听 binlog → 直写 ES,业务无感知
ES 不是数据库替代品

ES 写入有延迟、不保证强一致、更新成本高(常整文档重索引)。别把订单状态这种强一致数据只存在 ES。把 ES 当"搜索引擎 + 分析引擎",源永远在关系/文档库,靠 CDC 同步。这条线踩过的团队,没有不感谢当年听了这句话的。

集群、分片与容量规划

ES 数据按 index → shard(主分片)→ replica(副本) 组织。写入落到主分片、再同步副本;副本既提供高可用也分担读。分片数一旦设定很难改,所以建索引前就要估量:单分片建议 10–50 GB,太多分片 master 压力大,太少则无法水平扩展。

# 建索引时定分片与副本(之后改主分片数要重建) PUT /products { "settings": { "number_of_shards": 3, "number_of_replicas": 1 } }

mapping 设计:字段类型选错会返工

ES 的 mapping 类似"表结构"。字段类型定错(如该用 keyword 的用了 text,或该用 scaled_float 的用了 float)会导致搜不出或精度问题,且改 mapping 常要重建索引。核心区分:text 分词检索、keyword 精确匹配/聚合、date 范围查询。

# 规范 mapping:检索字段用 text,聚合/精确用 keyword PUT /products { "mappings": { "properties": { "title": { "type": "text", "analyzer": "ik_max_word" }, "brand": { "type": "keyword" }, "price": { "type": "scaled_float", "scaling_factor": 100 }, "created": { "type": "date" } } } }

论text 还是 keyword?

要"包含某词"的全文检索用 text(会分词);要"完全等于某值"或做 terms 聚合/排序用 keyword。很多新人把状态字段设成 text,结果聚合出一堆分词碎片。一个字段可同时映射两种(fields 多字段),兼顾检索与聚合。

# 多字段:同一内容两形态(检索 + 聚合) PUT /products { "mappings": { "properties": { "city": { "type":"text", "fields": { "raw": { "type":"keyword" } } } } } } # 检索用 city,精确/聚合用 city.raw,一处两用
4

分布式事务:没有银弹

2PC / TCC / Saga / 本地消息表

一个下单动作要同时扣库存、创建订单、减账户余额——它们在不同库甚至不同服务。单机 ACID 在这里失效,因为"事务边界"跨了节点。这一章讲主流方案与"为什么多数业务最终一致就够了"。

为什么单机 ACID 失效

本地事务靠"undo/redo log + 锁"保证要么全成要么全败。但跨服务时:服务 A 提交了自己的库,调用服务 B,B 失败了——A 已经提交了,没法回滚。网络还可能超时、丢包、重复,让"到底成没成"变成悬案。所以分布式下没有"真·原子提交",只有不同强度的近似方案。

论先问:真的需要分布式事务吗

很多"分布式事务"其实是设计问题:把本该一个聚合的数据拆到两个服务。先想能不能"合库、合并操作、用本地事务 + 幂等"解决。只有当数据天然跨域、又要求整体正确时,才上复杂方案。能不用就不用。

2PC:两阶段提交(强一致但危险)

协调者先发"准备":各参与者锁资源、写 undo/redo,回"就绪";都就绪再发"提交",否则全体回滚。问题是同步阻塞(准备阶段全程持锁)、协调者单点(它挂了参与者一直阻塞)、数据不一致风险(发了提交、部分参与者挂了)。所以 2PC 适合"库不多、要求强一致"的内部场景,互联网高并发很少裸用。

# 2PC 流程(协调者视角,示意) coord.prepare(parties) # 阶段1:都回 YES 才继续 if all_yes: coord.commit(parties) # 阶段2:提交;任一失败要补重试 else: coord.rollback(parties) # 有 NO 就全体回滚

TCC:Try / Confirm / Cancel

TCC 把每一步拆成三个业务动作:Try(预留资源,如冻结库存而非真扣)、Confirm(真正提交,幂等)、Cancel(释放预留)。它不依赖数据库锁,而是业务层自己实现补偿,所以灵活、并发高,但侵入大——每个接口都要写三套逻辑,且 Confirm/Cancel 必须幂等。

# 下单的 TCC try: stock.tryFreeze(sku, 1) # 冻结而非扣减 order.tryCreate(...) # 预创建(状态=待确认) confirm: stock.confirmDeduct() # 真扣(幂等) order.confirm() # 置为已支付 cancel: stock.unfreeze() # 回滚冻结 order.cancel()
TCC 的 Confirm/Cancel 必须幂等

网络重试会让 Confirm 被调用两次。若"扣库存"不是幂等,重复调用就多扣了。标准做法:用"已处理状态表"或业务版本号,确保同一动作多次执行结果一致。这是 TCC 上线翻车的第一原因。

Saga:长事务拆成一系列本地事务

跨多个服务的长流程(下单 → 支付 → 发货 → 通知),用 Saga:把大事务拆成一串本地事务,每步成功就走下一步;某步失败则反向补偿前面已成功的步骤。补偿顺序与正向相反("撤销链")。适合"最终一致可接受、流程长"的场景。

# Saga 正向与补偿(编排式) steps = [create_order, pay, ship, notify] for i, step in enumerate(steps): try: step() except: for s in reversed(steps[:i]): s.compensate() # 反向补偿 raise

论编排(Orchestration)vs 协同(Choreography)

编排:有个中心协调器(如工作流引擎)驱动每一步,流程清晰、易观测,但中心是瓶颈。协同:各服务监听事件自己决定下一步,去中心但流程散、难追踪。长流程首选编排(Temporal / Cadence / 自研状态机),可观测性吊打协同。

本地消息表:最终一致 + 幂等实战

这是最常用、性价比最高的方案:把"写业务"和"发消息"放进同一个本地事务——业务表更新 + 消息表插入一条"待发送"记录,一起提交(保证"业务成了消息必在")。后台任务扫消息表投递到 MQ,消费者幂等处理;发送成功再标"已发"。即使 MQ 暂时不可用,消息表还在,不会丢。

# 本地事务:业务 + 消息表 同库同事务 begin order_repo.insert(order) # 业务 msg_repo.insert("order.created", order) # 消息(待发) commit # 后台 worker 投递 + 标已发(至少一次投递) for m in msg_repo.pending(): mq.publish(m.topic, m.payload) msg_repo.mark_sent(m.id) # 消费者幂等兜底
消息"至少一次" → 消费者必须幂等

本地消息表/MQ 一般保证"至少一次投递",即可能重发。若消费者"扣库存"不幂等,重复消息就多扣。幂等三件套:① 用唯一业务键(order_id)去重;② 消费前查"是否已处理";③ 或数据库唯一索引兜底。所有下游消费端都要有这层。

对账:最后一道兜底

再完美的方案也会出意外(消息丢了、消费挂了)。对账是兜底:定时拿"源数据"(订单)和"衍生数据"(账户变动、库存流水)比对,发现不一致就自动/人工修复。金融系统几乎都靠"T+1 对账"兜住实时链路的零星误差——它承认"实时不一定百分百准,但隔夜一定准"。

# 对账伪代码:订单 vs 账户流水 for o in orders.since(yesterday): flow = account_flow.get(o.id) if flow is None or flow.amount != o.amount: alarm(f"订单 {o.id} 对账失败") reconcile(o) # 补单/冲正

消息队列可靠性:不丢、不重、不乱序

本地消息表把消息交给 MQ 后,还要保证 MQ 这一环可靠。三个诉求:不丢(生产者确认 + 持久化 + 消费者手动 ack)、不重(靠消费者幂等,前面讲过)、不乱序(同 key 路由到同分区)。这层不稳,最终一致就垮。

风险解法
消息丢失生产者 confirm + broker 持久化 + 消费者手动 ack
重复投递消费者幂等(唯一键 / 去重表)
顺序错乱同业务键进同一分区
# 消费者手动 ack(处理成功才确认,失败重投) msg = ch.basic_get(queue, auto_ack=False) try: handle(msg) # 幂等处理 ch.basic_ack(msg.delivery_tag) except: ch.basic_nack(msg.delivery_tag, requeue=True)
5

分库分表与读写分离

Sharding · Read/Write Split

单表过千万行、单库连接打满,就该拆。读写分离解决"读多写少";分库分表解决"单表太大 / 单库容量上限"。这一章讲清分片键这个生死线,以及怎么平滑迁移。

什么时候该拆:先优化,再拆

别一慢就分库分表——它是最重的手段。顺序应该是:① 慢查询加索引;② 冗余字段/汇总表减少 join;③ 冷热分离(老数据归档);④ 读写分离;⑤ 才到分库分表。只有当单表行数上千万且增长快、或单库连接/容量到顶,才值得承受分片带来的复杂度。

手段解决代价
加索引 / 改 SQL慢查询几乎无
冷热分离历史数据拖慢低
读写分离读多写少低(有一致性延迟)
分库分表单表/单库上限高(跨分片痛)

读写分离:主写从读

一主多从,写走主库,读走从库,分摊读压力。代价是主从延迟:刚写入的数据,从库可能还没同步到,"写完立刻读"会读到旧值。解法:写后强读的场景走主库;或引入"写后短暂读主"的路由规则。

# 读写分离路由(示意) def route(sql, just_wrote=False): if sql.startswith("SELECT") and not just_wrote: return slave_conn # 读走从 return master_conn # 写 / 刚写完 走主

分片键是生死线

分片键决定数据按什么规则落到哪个分片。选错,要么热点(数据全压一个分片),要么查询要广播所有分片。好的分片键:高基数(取值多)、贴合最高频查询维度、分布均匀。常见选 user_id / order_id 取模或哈希。

-- 按用户 id 取模分 16 个库(好) shard = user_id % 16 -- 坏:用"创建时间"分片 → 新数据全压最后一个分片(热点) -- 好:高基数业务主键,查询常带 user_id 可精准路由 SELECT * FROM orders WHERE user_id = 1001; -- 直落 1 个分片
分片键选错,上线后极难改

分片键一旦定下,历史数据按它分布,要换就得全量迁移 + 停写。所以上线前必须拿真实查询模式验证:"80% 的查询带哪个字段"。别凭感觉选。见过太多"上线半年发现热点全在一片"的血泪。

分片策略:范围 / 哈希 / 一致性哈希

范围分片(按时间/ID 段):简单、易范围查询,但新数据集中一片(热点)。哈希分片(取模):分布均匀、避免热点,但扩分片要重分布(取模基数变了)。一致性哈希:节点增减只影响邻近数据,扩缩容友好,是 Redis Cluster、Cassandra 的选择。扩分片前想清楚迁移成本。

# 一致性哈希:节点增减只动局部 ring = ConsistentHash(["db0","db1","db2"]) node = ring.get("user:1001") # 落在顺时针最近节点 # 加 db3 只迁移 db2→db3 之间那段,其余不动
策略优点缺点适合
范围分片易范围查询新数据热点时间有序、少写
哈希分片分布均匀扩分片要重分布通用
一致性哈希扩缩容只动局部实现复杂缓存/海量节点

跨分片查询痛点

分片后的噩梦:"按城市统计订单"这种不带分片键的查询,ES 里能落一片,分库分表里要广播到所有分片再汇总(scatter-gather),极慢且吃连接。解法:① 异构索引表(另建一张按城市的冗余表);② 冗余宽表;③ 把分析类查询交给 ES / 数据仓库,让分库分表只扛交易类带分片键的查询。

别用分库分表扛报表

运营要的"全量多维统计"天生和反分片键。硬在分片库上 group by,就是让每个分片都扫一遍。正确分工:交易读写为王用分片库,分析报表交给 ES/OLAP(ClickHouse)。一个系统两种引擎,各管一摊。

平滑迁移:双写 + 灰度 + 校验

从单库迁到分片库,不能"停机切"。标准姿势:① 双写(新旧库同时写);② 新老查询灰度比对;③ 用脚本全量+增量同步旧数据;④ 校验两边一致后切读;⑤ 观察稳定再停写旧库。整个过程可回滚,出问题随时退回旧库。

# 双写阶段(迁移期) def create_order(o): old_db.insert(o) # 旧库(保底) shard_db.insert(o) # 新分片库(验证中) verify_async(o.id) # 后台比对一致性

全局唯一 ID:分库分表的前提

分了库,自增主键就冲突了(每个分片都从 1 开始)。需要全局唯一、趋势递增、可排序的 ID。雪花算法(Snowflake)用 41 位时间戳 + 10 位机器位 + 12 位序列号,单机每毫秒可生成 4096 个不重复 ID,且按时间有序,对 B+树索引友好。

# 雪花 ID 结构(64 位) # 0 | 41 位时间戳 | 10 位机器 | 12 位序列 def snowflake(machine_id, ts, seq): return (ts << 22) | (machine_id << 12) | seq # 趋势递增 → 写入 B+树不随机,减少页分裂
时钟回拨是雪花的死穴

若机器时钟往回拨(NTP 校正、虚拟机迁移),可能生成重复 ID。解法:检测到回拨就等待或借用上次序列、或用 ZK 分配 workerId 并校验。高并发系统生成 ID 要单独压测,别在事务里实时算。

6

选型决策与真实组合

Putting It Together · 成本与运维

前面是零件,这一章把它们装成一台机器。一个典型电商系统的存储组合,能说明"多语言持久化"怎么真正落地,以及背后的成本与运维权衡。

电商存储分工全景

看一个订单系统的全貌,每种存储只干最擅长的事,靠应用层 + 异步同步串起来。

数据存储为什么
订单 / 账户 / 库存MySQL(分库分表)强一致事务源
购物车 / 秒杀计数 / 登录态Redis扛热点读、分布式锁
商品搜索 / 日志检索Elasticsearch全文 + 聚合(只读副本)
商品详情 / 评价草稿MongoDB结构多变、灵活 schema
图片 / 静态资源对象存储 + CDN分发给用户、便宜
监控指标时序库带时间戳指标

路它们之间怎么保持一致

下单写 MySQL(源)→ 发 MQ → 消费者更新 Redis(缓存)、同步 ES(检索)、减库存。每条链路都是最终一致 + 幂等 + 对账兜底。没有谁"实时强一致同步所有库",而是"源唯一、副本异步追"。

成本与运维权衡

每多一种存储,就多一套运维、监控、备份、告警、人员学习成本。不是越多越好。原则:能用一个关系库解决的,别上五个;只有当某个瓶颈真实存在且优化无效,才引入对应存储。引入前问"它的运维谁扛、挂了怎么降级"。

# 降级思路:Redis 挂了,读回源 MySQL(限流保护) val = redis.get(key) if val is None: with rate_limit("db-fallback"): # 防止 DB 被冲垮 val = mysql.query(key) redis.set(key, val, ttl=60) # 恢复后回填

一致性与可用性的取舍实例

同一系统里不同数据取舍不同:余额、订单状态选 CP(宁可短暂不可用也不能错);商品浏览量、推荐选 AP(丢几个计数无所谓,不能让页面转圈)。别给所有数据定同一致性目标——那是浪费。按"出错代价"分级。

数据类型取舍容忍的不一致
账户余额CP几乎零
订单状态CP(源)+ 近似读秒级
商品评分AP分钟级
PV/UV 统计AP可估算

容量与监控基线

引入多种存储后,监控要覆盖各自健康度:MySQL 慢查询数 / 主从延迟;Redis 命中率 / 内存使用 / 大 key;ES 写入延迟 / 段数 / 堆;MQ 积压。没有监控的多存储架构,等于闭眼开车。关键指标设告警阈值,而非事后救火。

# 几个必看的核心指标 redis-cli info stats | grep "instantaneous_ops_per_sec" redis-cli info memory | grep "used_memory_human" mysql -e "SHOW SLAVE STATUS\G" | grep "Seconds_Behind_Master" curl -s localhost:9200/_nodes/stats/indices?filter_path=**.indexing

选型决策清单(给自己用的)

把前面所有内容压成一张"决策树",下次选型直接照着走:

论五步选型法

① 数据形状?结构化→关系库;嵌套多变→文档库;KV→Redis。② 一致性强吗?强→CP 关系库;可最终一致→放宽。③ 查询形态?全文→ES;多跳关系→图;时序→时序库。④ 规模?单表千万+→分片。⑤ 运维扛得住吗?新存储要有 owner、监控、降级。五步走完,答案自然出来。

反模式总结

五种最常见翻车

① 把缓存当源(丢数据);② 把 ES 当主库(对账事故);③ 分片键拍脑袋(热点);④ 分布式事务裸用 2PC(阻塞+单点);⑤ 用分片库跑报表(全分片扫描)。这五条,本页每一章都在劝你别踩。

多活与容灾:存储如何跨机房

单机房有断电/断网风险。多存储架构的容灾策略各不相同:关系库用主从跨机房 + 半同步(丢数据少但延迟高);Redis 用跨机房复制或只读副本;ES 跨机房靠快照恢复 + CDC 重放。核心是"副本在异地、故障能切、数据能补"。

存储容灾手段
MySQL跨机房半同步主从 / MGR
Redis跨机房副本 + 本地读
ES快照 + CDC 异地重建
etcd跨 AZ 部署(奇数节点)

论容灾等级决定成本

同城双活、异地多活、两地三中心,级别越高越贵越复杂。先定 RTO/RPO(恢复时间 / 数据丢失目标),再选级别。多数业务"同城主从 + 定期备份 + 演练"已够;金融核心才上异地多活。别为"听起来稳"上超出预算的容灾。

结
本页要点

关系型之外按访问形态选存储(多语言持久化)。Redis 是内存数据结构服务:RDB/AOF/混合持久化保内存不丢,缓存三大坑(穿透→布隆/空值、击穿→互斥锁/逻辑过期、雪崩→抖动+集群)、分布式锁用 SET NX + Lua、高可用靠主从/哨兵/Cluster。Elasticsearch 用倒排索引做检索、分词器要配套、写入近实时、应作只读副本靠 CDC 同步。分布式事务没有银弹:2PC 强一致但阻塞、TCC 业务补偿、Saga 长流程、本地消息表 + 幂等 + 对账最常用。分库分表分片键是生死线、跨分片查询要交给 ES/OLAP、迁移走双写灰度。落地核心:源唯一、副本异步追、按出错代价分级一致性、每个存储有监控有降级。

自测 · 看你是否真懂

1.缓存穿透、击穿、雪崩三者区别是什么?分别怎么防?

查看答案

穿透=查根本不存在的 key,每次都打 DB(防:布隆过滤器 / 缓存空值);击穿=热点 key 过期瞬间并发打 DB(防:互斥锁重建 / 逻辑过期);雪崩=大量 key 同刻过期或 Redis 宕机(防:过期加随机抖动 / 集群高可用)。

2.用 Redis 做分布式锁,为什么释放锁要用 Lua 脚本?单实例 Redis 当锁有什么隐患?

查看答案

释放时要"先判断 value 是自己的、再删除",这两步必须原子,否则可能删掉别人刚抢到的锁——Lua 保证原子。单实例主从异步复制时,主挂锁未同步,从升主后别人可再抢同一把锁(锁丢失);强一致场景应换 etcd/ZK 或 Redlock,多数业务用"最终一致+幂等"更划算。

3.为什么 ES 不能当主数据库用?正确姿势是什么?

查看答案

ES 近实时(写入到可搜有秒级延迟)、不保证强一致、更新要整文档重索引、运维重。正确姿势:关系库为强一致源,ES 为通过 CDC(Canal/Debezium)同步的只读检索/分析副本。订单状态这类强一致数据只存 ES 必出对账事故。

4.分库分表最该先想清楚的是什么?选错会怎样?

查看答案

分片键。它决定数据分布与最高频查询能否落在一个分片内。选错(如用时间)会造成热点且上线后极难更改——要换就得全量迁移+停写。上线前须用真实查询模式验证"80% 查询带哪个字段"。

5.分布式事务里"本地消息表"为什么最常用?它依赖什么前提?

查看答案

因为它把"写业务+发消息"放进同一本地事务,保证业务成了消息必在,后台投递到 MQ,性价比高、不阻塞。前提是消费者必须幂等(至少一次投递会重发),且要有对账兜底最终一致。强一致核心链路才上 TCC/2PC。

下一步往哪走

路学完数据库进阶之后

① 动手搭一套:本地用 Docker 起 Redis + MySQL 主从 + ES,写个带缓存、防穿透、异步同步 ES 的接口,把本章链路跑通。

② 看 NoSQL 页:本页 Redis/ES 偏"用法与坑",NoSQL 页补 MongoDB/Cassandra/Neo4j 等其它形态,拼齐多语言持久化全景。

③ 看系统设计与 DevOps:缓存、分片、限流、可观测都是系统设计的拼图,配合《系统设计》一起学才能落地架构。

④ 深入可观测:慢查询、缓存命中率、ES 写入延迟——这些要靠监控才能调优,别等线上炸了才看。