本篇要回答的问题:最广泛的存储中间件——数据库有哪几类(关系型/文档型/KV)?要不要支持事务、乐观锁怎么实现?为可用性与持久性引入的主从结构和分布式各自要解决什么、又受 CAP 怎样约束?
上一讲讲了存储中间件的由来,并指出"存储即数据结构"。本讲深入最常用的存储中间件——数据库,从使用界面(选型)到实现(事务、主从、分布式)走一遍。
数据库的种类
从使用界面(接口)角度:
| 类型 | 代表 | 数据单位 | schema | 特点 |
|---|---|---|---|---|
| 关系型 | MySQL、Oracle、SQLServer | row 分成多个 column | 强 schema,column 有明确类型 | 复杂项另起一张表、主表存 ID 引用;表≈结构体,嵌套结构体=子表 |
| 文档型 | MongoDB | document(JSON 等) | 大多无 schema,插入不校验格式 | 好处:门槛低、升级格式方便;坏处:质量保障弱、数据可能被弄脏而不自知(未来或有强 schema 文档库) |
| 键值存储 | Cassandra | Key→Value | 仅唯一索引 | 使用上是数据库特例(数据库可多索引);实现上是数据库基础——每组数据库索引背后往往就是一组 KV |
事务
这一节最绕,建议配合紧随其后的 [[#为什么:事务这一节到底在说什么]] 一起读——那节拆开讲:事务在防什么 / 乐观锁 ≈ git / 为什么要记读条件 / 锁的是时长而不是范围。
任何数据库都面临的重大、艰难选择:是否支持事务。需求上事务很强大没道理不支持;实现上事务负担极大,尤其分布式场景。事务特性 ACID:
| 字母 | 特性 | 含义 |
|---|---|---|
| A | 原子性(Atomicity) | 全部完成或全部不做,出错全回滚,如同没执行过 |
| C | 一致性(Consistency) | 执行须保证系统一致(A 转 B 50 元,无论并行发生什么,A=450、B=350) |
| I | 隔离性(Isolation) | 事务互不影响,中间状态不被他人感知 |
| D | 持久性(Durability) | 一旦完成,变更完全保存,停电宕机亦然 |
为什么分布式数据库讨厌事务:忽略性能时事务很好实现(一把锁住整库的大锁即可),但大锁会让整库废掉。没有事务时,一次操作可按数据分区特征快速落到某分区实例,退化为单机操作;有事务则不行。
乐观锁(常见事务实现):
| 对比 | 常规锁 | 乐观锁 |
|---|---|---|
| 策略 | 先互斥再改数据(不管是否冲突都先互斥) | 先算出所有修改,最后一步统一提交,提交时做冲突检查 |
| 结果 | —— | 无冲突(之前无人提交新版本,或新版本与我依赖的数据不相关)则成功;否则放弃本次修改 |
机制要点:
- 每个数据可有多个版本:
KEYᵢ -> [(VER0,VAL0),(VER1,VAL1),...],VER0 是当前已提交值。 - 事务还要记录读条件(不是读过的 KEY 列表)。如
SELECT ... WHERE age>17,要记作"读过所有 age>17 的条目"。 - 提交时锁住整库(修改阶段事务间不冲突、无需锁库),检查所有读条件对应条目的已提交版本是否都 ≤ 基版本 VER0:是则提交并释放锁;否则放弃并回滚(删除该事务产生的版本记录)。
为什么用乐观锁:把锁库粒度降到最低,判断冲突的逻辑可预期,避免死锁;且可推理出并行事务中必有一个提交成功,避免饥饿。
为什么:事务这一节到底在说什么
原文把三件不同的事压在了一节里——事务在防什么、大锁为什么不行、乐观锁怎么绕开大锁。拆开就不绕了。
一、事务到底在防什么:两种灾难
转账,A 给 B 转 50:
balance["A"] -= 50
balance["B"] += 50
这两行之间有两种灾难:
- 灾难① 崩溃:第一行跑完,进程挂了 → 50 块凭空消失。
- 灾难② 并发:另一个请求恰好在这两行中间来查账,看到 A 少了 50、B 还没加上 → 全库总额对不上。
对着 ACID 看:
| 字母 | 它防的是哪种灾难 |
|---|---|
| A 原子性 | 灾难①:要么两行都算数,要么都不算数 |
| D 持久性 | 灾难①:说了成功就绝不能因断电而回退(即上一讲"ok 不许是假的") |
| I 隔离性 | 灾难②:别人看不到我这两行中间的状态 |
| C 一致性 | 不是一个机制,是结果:不管发生什么,A+B 永远等于 800 |
关键:ACID 不是并列的四件事。A/D 对付崩溃,I 对付并发,C 是前三者做到之后想要的那个结果。原文并排列四条,最容易让人以为要分别实现四个东西。
二、"一把大锁"能解决,但不能用
lock = threading.Lock()
def transfer(a, b, amt):
with lock: # 锁住整个数据库
balance[a] -= amt
balance[b] += amt
这确实是完整的 ACID。问题在于真实的 with lock: 里不止两行——可能含几十次磁盘 IO、甚至一次跨机器网络往返。这段时间全世界所有请求都在排队,吞吐直接掉成"1 ÷ 一个事务的耗时"。
分布式为什么更讨厌事务:没有事务时,一个请求按 key 算个哈希就知道该找哪台分片机,接下来纯粹是那一台的单机操作,十台机器互不相干、吞吐是十倍。一旦一个事务要同时改 A(在 3 号机)和 B(在 7 号机),这两台就必须协调——谁先谁后、一台成功另一台失败怎么办。分片好不容易换来的"互相独立"被事务打破了,这才是"负担极大"的真正含义。
三、乐观锁 = 把 git 那套搬进数据库
| 悲观锁(常规锁) | 乐观锁 | |
|---|---|---|
| 打比方 | 改文件前先把文件锁死,别人不许碰 | 各自在自己分支上改,提交时才检查撞没撞车 |
| 顺序 | 先互斥 → 再改 | 先改(改在草稿上)→ 提交时检查 → 没撞车才生效 |
| 代价 | 不管有没有冲突,都先付出等待 | 冲突罕见时几乎零成本;真撞车了整个事务白做、重来 |
第 1 步 · 每个 key 存一串版本,而不是一个值
KEY_A -> [(VER0, 500), (VER1, 450)]
↑已提交的 ↑我这个事务改出来的,别人看不见
就是 git 里一个文件的 commit 链:VER0 是 main 上的当前值,VER1 是我本地那个还没 push 的 commit。我改我的,完全不挡别人读 VER0——这就是原文"修改过程事务间不冲突,所以不需要锁"的意思。
第 2 步 · 记下我读过什么(最反直觉的一步)
原文说:不能只记读过哪些 key,必须记读条件。为什么?
SELECT count(*) FROM users WHERE age > 17 -- 我读到 100 人
假设我只记"我读了 id=1…100 这 100 行"。这时另一个事务插入了一个 age=20 的新人并抢先提交。我提交时挨个检查那 100 行——一行都没被改过,检查通过。可我整个事务是基于"成年人有 100 个"做的决策,而现在是 101 个。
我读过的行没变,但我读到的那个"集合"变了。 这个现象数据库里叫幻读(Phantom Read)。
所以必须记下条件本身 age > 17:提交时检查"有没有任何一条满足 age>17 的记录在我开始之后被提交过"——新插进来的那个人也落在这个条件里,冲突就被抓住了。
第 3 步 · 提交时锁整库,检查,然后立刻放
def commit(tx):
with global_lock: # 只在这一刻上锁
for cond in tx.read_conditions: # 逐条检查读条件
if 存在满足 cond 的行.已提交版本 > tx.base_ver:
rollback(tx) # 删掉我造的那些版本记录
return FAIL
publish(tx.writes) # 我的 VER1 转正成已提交版本
return OK
最容易读拧的点:这不也锁了整个数据库吗?跟"一把大锁"有什么区别?
区别在锁多久,不在锁多大。大锁方案里锁横跨整个事务——期间要做 IO、跑业务逻辑、还可能等网络;乐观锁只在最后做一次纯内存的版本号比对,微秒级。原文说"把锁数据库的粒度降到最低",降的是时间粒度,不是范围。
四、顺着推出的两个结论
| 结论 | 为什么 |
|---|---|
| 不会死锁 | 死锁成因永远是"我拿着 A 等 B、你拿着 B 等 A",成环。乐观锁全程只有一把锁,且提交那一瞬间获取、检查完立刻释放,不存在"持有一把锁的同时去等另一把"——没有环可以成 |
| 不会饥饿 | 一批事务同时挤到提交这步,总有一个先抢到全局锁;对它来说没人在它之前提交过,所以它必然成功。系统整体一定在前进,不会所有人一起失败原地空转 |
原文没讲的边界:不饥饿保证的是系统级(总有人成功),不是个体级。某一行若是热点、你的事务又跑得慢,它完全可能一次次撞车一次次重来。这正是乐观锁的适用边界——冲突罕见才划算,冲突密集时反而不如老实上悲观锁。
原文那几句话的"人话版"
| 原文 | 人话 |
|---|---|
| 事务就是把一系列操作变成原子操作 | 中间挂了不留半截,中间被看到也不留半截 |
| 忽略性能的话一把大锁就够了 | 对,但那等于把数据库变成单线程 |
| 每个数据可能有多个值 | 就是 git 的 commit 链,我的改动先待在"未 push"状态 |
| 要记录的不是 KEY 列表而是读条件 | 防幻读:别人新插的行也得算进我的冲突检查 |
| 提交时锁住整个数据库 | 只锁那一瞬间的版本号比对,微秒级 |
| 锁的粒度降到最低 | 降的是时长,不是范围 |
主从结构
一旦考虑业务可用性与数据持久性,就需多副本——而多副本带来一致性问题(听谁的)。
| 术语 | 关注 |
|---|---|
| 可用性(Availability) | 业务是否正常工作 |
| 持久性(Durability) | 数据是否会被异常丢失 |
主从(Master-Slave):一主多从,所有写发往 Master,所有 Slave 从 Master 同步。要点:
| 要点 | 说明 |
|---|---|
| Slave 只会更旧不会更新 | 因同步未完成而版本旧,不会比 Master 新 |
| Slave 分担读,但有限 | 多数读须读最新数据,否则逻辑错乱;只有纯界面呈现、非敏感(财务是敏感场景)才可读旧 |
| 互备与选举 | Master 挂时某 Slave 接替,需超过半数节点同意 → 节点数取奇数较好(2 节点时主挂后剩 1 不过半,选不出新主) |
| 防版本回退 | 选数据非最新的 Slave 当主=版本回退=丢数据,不可接受;故写操作应确保至少一个 Slave 收到最新数据,才能在主挂后选出有最新数据的节点 |
分布式
多副本保障了可用性与持久性,但仍有两问题:单节点放不下海量数据;单主承受不了过高 IOPS(如今单机存储量已很大,第二种情况更常见)。
解法是分布式:把数据分片到多台分片服务器,一起构成一个单副本数据库。分片方式:
| 方式 | 英文 |
|---|---|
| 哈希分片 | Hash based sharding |
| 范围分片 | Range based sharding |
无论哪种,扩缩容都会触发重新分片→数据搬迁;迁移阶段同一分片一部分数据在源节点、一部分在目标节点,对持续访问是不低的挑战。
CAP 理论(三者不可兼得,只能取其二):
| 目标 | 含义 |
|---|---|
| 一致性(C) | 写成功后之后的读都读到新数据;写失败则都读不到 |
| 可用性(A) | 读写在一定时间内得到响应,可终止、不会一直等 |
| 分区容错性(P) | 网络分区下被分隔的节点仍能正常对外服务 |
关键判断:通常不放弃可用性(A),于是主要在 C 与 P 之间权衡。一致性的选择由业务特性决定——业务要求强一致就不能用最终一致性模型,相应只能在分区容错性(P)上取舍。
总结
本讲从使用界面(选型)到实现两条线讨论数据库。选型先看关系型/文档型/KV 与是否要事务;实现上,事务多用乐观锁(最小锁粒度、避免死锁与饥饿),可用性与持久性靠主从(一主多从、奇数节点选举、写须至少一从收到最新数据防回退),规模与吞吐靠分布式分片(哈希/范围,扩缩容要搬数据),并最终受 CAP 约束——通常保 A,在 C 与 P 间按业务取舍。
先把"是什么"回答清楚
| 概念 | 一句话说明 |
|---|---|
| 关系型/文档型/KV | 强 schema 多索引 / JSON 多无 schema / 唯一索引(数据库的特例兼实现基础) |
| ACID | 原子性、一致性、隔离性、持久性 |
| 乐观锁 | 先算改动最后统一提交、提交时按读条件检测冲突;最小锁粒度、避免死锁与饥饿 |
| 读条件 | 事务要记录的不是读过的 KEY,而是查询条件(如 age>17 的全部条目) |
| 主从结构 | 一主多从,写发主、从同步;奇数节点选举、写须至少一从最新以防版本回退 |
| 分布式分片 | 哈希/范围分片构成单副本库,扩缩容需重新分片搬数据 |
| CAP | C/A/P 三者取其二,通常保 A,在 C 与 P 间按业务取舍 |
一句话速记
数据库选型看"关系/文档/KV + 要不要事务";实现上事务靠乐观锁(最小锁、防死锁饥饿),可用持久靠主从(奇数节点、写须一从最新防回退),规模吞吐靠分布式分片——而这一切最终被 CAP 锁死:保住 A,就在 C 和 P 之间按业务做取舍。
几条值得记住的判断
- KV 是数据库的基础:每组索引背后往往就是一组 KV。
- 乐观锁的精髓是最小化锁库:故能避免死锁,并保证总有一个事务成功(无饥饿)。
- 写须至少一从最新:这是主从防版本回退、不丢数据的关键约束。
思考题
CAP 通常在保住可用性(A)的前提下,于一致性(C)与分区容错性(P)之间取舍,而 C 的强弱由业务决定。回到你的业务:哪些数据必须强一致(如财务/库存),哪些可接受最终一致(如点赞数/界面展示)?你会据此为它们选择怎样不同的存储与一致性策略?
