加载中...

本篇要回答的问题:最广泛的存储中间件——数据库有哪几类(关系型/文档型/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 的强弱由业务决定。回到你的业务:哪些数据必须强一致(如财务/库存),哪些可接受最终一致(如点赞数/界面展示)?你会据此为它们选择怎样不同的存储与一致性策略?

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关