last modified: 2023-01-09 20:35
把数据库,从大的方面分为两个部分,可以称为内部和外在。
其外在是与用户和业务交互相关的部分,有,
内部部分有,
这份总结只涉及内部,不涉及外在。
数据文件存储,主要关注的问题是,field在record中怎么存储(field就是一个字段,record就是row),record在文件中怎么存储。
但计算机中,不管HDD还是SSD用的都是块存储设备,操作系统提供的读写磁盘的最小单位是block,文件实际是由一个个的block构成。
所以record在文件中怎么存储,要看的是record在block中的存储。
field有定长(如long)和变长(如varchar),对于record全部field都是定长的情况,只需要根据字段类型的元数据,可以直接定位到field。
对于变长的情况,可以记录各field的offset和size,变长的内容本身放到record的尾部,同样可以直接定位。
变长record的存储示例:
Bytes 0-4的内容表示,offset为21,长度为5,即field:10111,

fields全部为定长的record,record本身也是定长。所以根据block_id和record size就可以遍历截取数据。
对于含有变长fields的record,record长度亦不定,则需要一个特殊的结构来从block中取数据,这个结构叫 Slotted Page Structure

在Block header中存储Records在Block中的Index位置及长度,而records本身存在block尾部。
我们的数据库都是文件的形式存在,至于文件放在哪些block里面,这个信息是记录在inode中(linux),通过stat命令可以看inode的部分信息,比如下面nohup.out文件由56个block组成,block大小为4K:
:~# stat nohup.out
File: nohup.out
Size: 24023 Blocks: 56 IO Block: 4096 regular file
Device: fc01h/64513d Inode: 1310941 Links: 1
Access: (0600/-rw-------) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2022-03-28 16:11:12.115055464 +0800
Modify: 2023-01-09 02:25:06.695382964 +0800
Change: 2023-01-09 02:25:06.695382964 +0800
Birth: -
上述的record,指的是数据的行存,另外数据亦可以进行列存:同一列的数据在文件中聚集在一起。
列存是面向decision support来设计的(行存面向的是transaction processing),它比较适合大数据场景,因为大数据场景中经常需要做整列的SUM/DISTINCT等计算工作,列存下只需要读取部分列数据以减小I/O,并且列的数据类型都相同,可以为它选择更好的编码算法。
但本质上,列存的数据文件存储与行存相比,没什么特殊之处。
如果没有索引,并且数据文件中的record是随机存储的,那么取想要的数据,只能通过遍历所有数据文件。而因为磁盘I/O慢的缘故,通过文件遍历来查找数据的效率是极低的。
如果希望快速检索数据,则需要为数据建立索引。这些索引列也叫:Search key。
Search key列的数据本身如果是按顺序排列的,则即可以建Dense索引,也可以建Sparse索引,Sparse索引只对部分record建立索引,但因为数据列本身有序,可以通过先使用Sparse索引进行初步定位,再局部遍历的方式取到数据。比如可以令每个block的数据一个索引,而不是一行一个索引。
有序列上的索引也称Clustering index(聚簇索引)。需要注意的是,Clustering index是指数据行按照该索引的顺序物理存储,而Primary index是主键索引。在InnoDB存储引擎中,主键索引就是聚簇索引,但在其他存储引擎(如MyISAM)中,主键索引也是非聚簇的。比如MySQL InnoDB的主键索引(AUTO_INCREMENT id)就是聚簇索引
下面讨论三种索引技术:
顺序索引指,索引数据 按search key排序,以B+树为例,
假设以下表结构:
id, name, object, salary
若为name列建立顺序索引,B+树结构为,

叶子节点存储name值,及name值对应的数据文件指针(block_id),亦或者是name对应的Primary id,再通过Primary index索引,定位到数据文件指针。
相同hash值的search key,其在索引文件中聚集。表现在存储上,相同hash值的search key保存在相同block中,若block存满,则溢出到其它block中,这些block间拉链。
hash索引适合点查,不适合范围查找。
LSM-Tree是为大数据量写入而优化的索引结构,它的变种在大数据领域应用广泛,如ClickHouse,HBase,TiDB等底层存储,均使用了LSM-Tree及其变种技术。
对其的详细分析: LSMTree compaction

有三种数据partition方式,
在数据库中很少使用,在Kafka等消息队列产品中使用比较多,因为其balance性最好,缺点是查询要访问所有partition,而MQ一般不提供查询功能,因此MQ使用场景下不算什么缺点。
优点是点查性能良好,缺点是range查询需要访问所有节点,效率较差。
定义:

它的核心设施是range vector table,记录了节点与数据range之间的映射关系。
但Range partition会面临由于Range vector table选择和维护不好,而导致的数据倾斜问题:一些range内的元素数多,一些range内的元素数少。因为range是放在节点的,就导致有的节点数据量多,有的节点数据量少,发生数据倾斜。
因为业务使用场景(比如用month做partition,某月的数据量特别大)、业务的不同周期(如按shop做partition,但某个shop突然大卖,其所在的range数据变多)出现倾斜。
解决这个问题的核心,是需要让Range vector table动态变化,满足业务数据的动态变化,确保每个range包含的数据条目动态平衡。
解决Range partition的数据倾斜问题,有个组合方案,
虚拟节点的原理是将数据range到物理节点的关系,变为数据range到虚拟节点的关系,物理节点与虚拟节点是1:N,这样一个倾斜的数据range会分散到不同的虚拟节点。
虚拟节点(包括存储的数据)可以很方便的在物理节点间做迁移,从负载高的节点迁移到负载低的节点。如果数据range的数据量增长,可以增加物理节点,并将该range的部分虚拟节点迁移到新物理节点上,它实现了存储的弹性。


虚拟节点fixed partition vector的方式,解决不了range数据量随时间持续变化的问题,这个问题可以使用动态re-partition来解决。
当virtual node增长到一定大小,可以split成2个新virtual node,其中一个virtual node即可以分布到其它负载较低的节点。(HBase里的auto split,这种场景下的virtual node也叫tablet)

一个方案是Master模式,有专门的Router节点,它同步Master的Partition vector table信息。专门负责路由请求。

另一种是P2P方案,它没有Master节点,也没有Router节点。它使用的是Consistent hashing(一致性哈希)和DHT(Distributed Hash Table)技术,DHT基于Consistent hashing技术。
首先有一个环,环上分布了非常多的vnode(比如2的20次方个),数据通过一个hash函数分布到vnode上,物理节点通过另一个hash函数分布到环上。
每个物理节点负责存储逆时针方向的vnode上的数据。请求到达任意一个node(物理),该node称为这次请求的coordinater,它已经通过Gossip协议同步了Vector table信息:物理node所负责的vnode。
如:一个get操作,coordinater计算其hash得到其vnode,通过Vector table知道请求具体转发到哪个物理node。

这里讨论的Replication,不是record level的,而是上面讨论的partition level(range->partition->virtual node/tablet)。
Replication主要存在lost update,即数据一致性的问题,

不同Replica数据的一致是我们的目标,Replica间的数据同步,我们通常使用,
2PC是分布式事务的经典解决方案,它本质是做多节点的原子提交(atomic commit protocol)。但如果把多节点的不同操作,都变成set x=y的相同赋值操作,也变相实现了数据同步的功能。需要注意的是,2PC存在blocking问题,在生产环境中需要谨慎使用,很多场景会采用最终一致性方案(如Saga、本地消息表)或TCC来替代。
Replication的实现在Transaction Replica稍详细总结。
Transaction的目的是C,C除了一致性的解释以外,在这个场景下有个更贴近的描述:使数据库的数据恰如你预期。
举个例子:
张三李四原本分别有10元钱,张三给李四转账5元,当数据库告诉你转账成功后,则任何时机查询,张三都应该有5元,李四都应该有15元,SUM(张三,李四)都应该是20元。不会存在错误的状态,比如张三5元,李四仍10元。
Transaction的实现办法是A(Atomicity原子性)和D(持久性Durability)。A和D都保证了,也就实现了C。
A和D已经实现了C,但前提是,不同的Transaction都要One by one的串行执行,不存在并发执行的情况。串行带来了性能问题。要解决Transaction的性能问题,引入了Concurrency control机制,而Concurrency引起了I(Isolation)的问题。
数据库的Recovery子系统,负责实现A和D。要理解其实现,首先要理解不一致的问题出在哪里。如果我们有一个no failure的理想数据库,我们的每次写入原子且必然成功,就什么也不用做了,问题出在failure。
具体到数据的写入阶段:

知道了问题,解决思路就比较简单,
比如,Transaction T中,B向A转账50,其transaction过程为:
<T start>
<T, A, 1000, 1050> - step2
<T, B, 1000, 950>
<T commit>
若log写到step2时系统fail了,
<T start>
<T, A, 1000, 1050>
Rollback的过程(Undo),
<T start>
<T, A, 1000, 1050>
<T, A, 1000> - 恢复A
<T abort> - transaction abort
Undo过程处理未完成的transaction,生成undo log,接下来执行Redo:将该transaction从start到abort重新执行一遍。
以上有这样一套log机制了。目前我们有数据和log两种文件,这两种文件的落地规则需要一定的约束,不然log未落地,但数据落地,系统fail恢复后会有脏数据等问题。
WAL(Write-ahead log)是这样一套规则,它约束了log与数据的落地过程,
在WAL的前提下,我们来看下数据文件落盘的策略,
Transaction T commit前,若允许数据文件开始落盘(注意要遵守上面的WAL规则,这些数据更新的log已经落盘了),则称为steal policy,比起commit后才开始写数据文件,这有助于分散减小I/O压力。
如果Transaction T commit后,不要求数据文件立马落盘,则称为No-force policy,No-force policy允许数据文件在Transaction提交后慢慢写入,亦有助于分散I/O压力。
理想的,以及MySQL等默认的选择都是Steal+No-force policy。
Buffer Pool是数据库性能的关键,它是内存中的数据页缓存,用于减少磁盘I/O。
Buffer Pool由多个page组成,每个page通常为16KB(MySQL InnoDB默认值)。Page在内存中的组织包括:
传统LRU存在的问题:
MySQL InnoDB的解决方案:
将LRU list分为两部分:
新读入的page首先放入Old区的头部(midpoint位置),只有在Old区停留时间超过阈值(默认1秒)且再次被访问,才移到Young区头部。
LRU List: [Young区 (5/8)] <- midpoint -> [Old区 (3/8)]
↑ ↑
热数据区 新数据首次插入这里
参数配置:
innodb_old_blocks_pct:Old区占比,默认37(即3/8)innodb_old_blocks_time:page在Old区的存活时间阈值,默认1000ms脏页是指在内存中被修改但还未写入磁盘的page。刷盘策略包括:
1. 自适应刷新(Adaptive Flushing)
2. 刷新时机:
3. 刷新算法:
相关参数:
innodb_max_dirty_pages_pct:脏页比例阈值,默认75%innodb_io_capacity:磁盘IO能力,影响刷盘速度数据库重启后,Buffer Pool为空,会出现”冷启动”问题,性能较差。
预热策略:
相关参数:
innodb_buffer_pool_dump_at_shutdown=1:关闭时保存innodb_buffer_pool_load_at_startup=1:启动时加载经验法则:
监控指标:
Transaction如果One by one的串行执行没有什么问题,但如果两个Transaction并发执行,它们又修改了相同的数据,则会产生冲突。
解决Transaction并发执行冲突问题,有两种方案,
Transaction中常用2PL,锁是一种悲观解决方案。2PL分为两个阶段:
2PL能保证Serializability(可串行化),但可能导致死锁问题(后续章节详述)。
快照是一种乐观的解决方案,基于MVCC(Multi-Version Concurrency Control,多版本并发控制)实现。每个事务读取数据时,看到的是事务开始时刻的一致性快照。写入时通过版本链管理多个版本。
Transaction提交时,如果多个Transaction更新了同一份数据,则产生冲突,一般采用First-committer-wins的冲突解决方案。
快照比较适合于写入冲突不太严重的场景,读操作不会阻塞写操作,写操作也不会阻塞读操作,因此并发性能较好。
Snapshot Isolation的异常
快照隔离存在Write Skew问题:
两个值A=1000,B=2000,
两个Transaction T1/T2,并发读A和B,T1执行: A=B,T2执行:B=A,T1/T2两个Transaction分别提交之。
则结果是A=2000,B=1000,两个值互换了,两个Transaction都成功,因为写了不同的值,因此没有冲突。
但我们期待的结果是,要么A=B=1000,要么A=B=2000,取决于T1/T2提交的先后次序。
这种异常说明Snapshot Isolation并不能保证Serializability。要解决这个问题,需要使用Serializable Snapshot Isolation(SSI),通过检测读写依赖来避免这种异常。
MVCC是实现Snapshot Isolation的核心技术,它允许读写并发执行而不互相阻塞。MVCC的核心思想是为每次数据更新保留一个版本,不同的事务可以看到不同版本的数据。
以MySQL InnoDB为例,每行记录包含以下隐藏列:
通过DB_ROLL_PTR,同一行的多个版本形成一条版本链(undo log链):
最新版本 -> 版本2 -> 版本1 -> 最老版本
UPDATE操作会:
事务读取数据时,会生成一个ReadView,包含:
对于版本链上的每个版本,通过以下规则判断是否可见:
if (DB_TRX_ID < min_trx_id) {
// 该版本在所有活跃事务开始前就提交了,可见
return VISIBLE;
} else if (DB_TRX_ID >= max_trx_id) {
// 该版本是在当前事务开始后才创建的,不可见
return NOT_VISIBLE;
} else if (DB_TRX_ID in m_ids) {
// 该版本是由未提交的事务创建的,不可见
return NOT_VISIBLE;
} else {
// 该版本是由已提交的事务创建的,可见
return VISIBLE;
}
不同的事务隔离级别通过不同的ReadView生成时机来实现:
Read Committed(RC)
Repeatable Read(RR)
Serializable
使用锁进行并发控制时,可能出现死锁问题。
经典的死锁场景:
事务T1:持有锁A,等待锁B
事务T2:持有锁B,等待锁A
数据库通过wait-for graph检测死锁:
死锁检测算法:
环检测可以使用DFS算法,时间复杂度O(V+E)。
1. 超时机制
2. 选择牺牲者(Victim Selection)
3. Wait-Die / Wound-Wait
应用层最佳实践:
相比单机,分布式多了Partition和Replication。我们在分布式中,追求的仍然是AID->C。
在分布式环境中,对于AD来说,如果一个transaction的更新请求都路由到同一个节点,则依靠单机能力,已经可以实现分布式环境的AD保障。其中单机完成local transaction(global transaction的sub transaction),分布式即完成global transaction。
但如果transaction的操作涉及多个节点,则需要一种额外机制,这个机制是2PC。
2PC涉及2个角色,coordinator(协调者)节点和participant(参与者)节点。

2PC最大的问题是,它是一个blocking协议,当协调节点fail且恢复不了,某些Participant将处于resolve状态:参与节点已经成功发送agreement消息(Vote Yes),它将一直阻塞在这里,直到收到协调者的commit或abort消息。
2PC的难点不在协议本身,两个阶段比较好理解。它的难点在于Coordinator或Participant fail情况的处理,分别讨论,
Participant在收到prepare T消息并写ready T/No T log后,就一直 blocking等待Coordinator回复。Coordinator恢复后执行recovery并咨询所有Participant ready T or abort T,根据回应执行commit T/abort T流程
Participant在收到commit T/abort T消息并写commit T/abort T log后,sub transaction就算完成了。Coordinator恢复后执行 recovery并询问所有Participant是否已经commit T/abort T,如果都执行了就可以end T并回复client
因为2PC第二阶段里Coordinator必须要所有节点都回应ready T才能 commit,那现在Coordinator就收不到fail节点的任何回应,自然应该 abort T。
fail节点恢复后,如果fail前只写了sub transaction log records 但没有写No T或者 Ready T log,意味着该节点没有正常参与2PC,直接执行单节点recovery即可,这些已写的log record都会被 undo。
如果fail前写了ready T/No T log,意味着该节点正常参与了2PC(尽管实际上没有回应prepare T),那么就需要主动询问Coordinator关于T的结果,结合abort T的结果再执行recovery并ACK Coordinator,因为在2PC里只有Coordinator有权决定T是commit还是 abort,如果本地直接recovery undo T就会违反2PC
Coordinator正常根据所有节点的prepare T回应判断执行commit T/abort T流程即可。
fail节点恢复后,因为fail前肯定写了ready T/No T log,跟上一种情况一样,需要主动咨询Coordinator 关于T的结果,结合T的结果再执行recovery并ACK Coordinator
Coordinator不需要额外动作。fail节点恢复后,同样需要主动咨询 Coordinator关于T的结果,结合T的结果再执行recovery并ACK Coordinator
Coordinator不需要额外动作。fail节点恢复后,正常执行recovery并 ACK Coordinator
2PC+单机transaction,实现了分布式transaction的AD功能,其中2PC+单机log保障A,单机log保障D。
2PC blocking问题,根源来自Coordinator的单点故障,只有Coordinator了解本次transaction的状态及全局结果。后面讨论Replication时,看如何解决该问题。
分布式下锁机制与单机一致,区别是分布式下需要一个中心化的lock manager来解决deadlock的问题,它需要一个全局视角来构建dependency graph.
分布式Snapshot的问题在于,相同transaction T在不同Node,可能读取的是不同版本快照。

如T2在Node1读取的是T1更新前的数据版本,而在Node2读取的是T1更新后的数据版本。这导致T2看来,某一时间SUM(A, B)=3050,与实际的3000不符。
因此也需要一个中心化的角色,来控制transaction访问的数据版本(只有commit的版本才会被其它transaction看见),以及更新冲突时以哪个版本为准。
上面讲的是引入partition的情况下,导致的transaction处理算法变化。
实际上,分布式系统为了高可用,还引入了replication。理想的状态,某个partition的写成功,代表的应该是其所有replica都写成功。而所有replica都写成功,这是atomic commit protocol,比如2PC提供的能力。
但replication的目的是高可用,如果因为某一个replica fail就写失败了,是违背高可用原则的,需要对2PC进行一些改造,使他不需要所有Participant都Vote才能完成。
这就是Quorum机制,它仍基于2PC的原理,并有两条原则:
比如W = 2,则只需2PC的写两个replica即可。
Quorum的问题在于,我们需要读多份数据,才能比较出最新我们需要的数据。
而另外也有一种Primary replication的方式,数据的读写都在同一个节点,而这个节点负责将数据同步给其它replica(W > N/2,即同步更新半数以上节点)。
当Primary节点fail时,需要从replica中选举出新的Primary节点,这个过程叫leader选举。Raft是Primary replication+leader选举这套算法的工程实现。
首先raft是基于log的协议,partition中的数据可看作是log,log记录都是append追加的,每个log记录都有一个index。多个replica中有一个是 leader,其他的都是follower。raft将时间线划分成了term,term编号是递增的,每一届term中都有唯一的leader。
每个log记录标识了写入该记录的term.

假设某个时刻replica中的log记录分布是这样的,此时leader fail,follower在一段时间内没有收到leader的心跳,则判断leader已经fail,启动leader election流程。
该follower给其他所有replica发送RequestVote请求(current term id+1,index)。其他replica收到 RequestVote请求后,判断如果发现自己的log比候选人的log更加新,则Vote no,否则Vote yes,log比较的规则是:
这样的话这个replica就获取了包括它自己在内大多数replica的投票,被选举为新leader.

Leader被选举出来后,开始处理客户端请求。日志复制过程如下:
1. Leader接收请求
2. Leader复制日志(AppendEntries RPC)
term:Leader的termprevLogIndex:新entry前一条log的indexprevLogTerm:新entry前一条log的termentries[]:要复制的log entriesleaderCommit:Leader的commit index3. Follower响应
4. Leader提交日志
5. Follower应用日志
Commit Index:
Applied Index:
Leader Completeness Property:
如果一个log entry在某个term被commit,那么这个log entry一定会出现在所有更高term的Leader的log中。
这个性质通过leader选举时的限制保证:
Log Matching Property:
这个性质通过AppendEntries的一致性检查保证。
Follower的log可能比Leader多或少,Raft通过以下机制处理:
1. Leader强制覆盖
2. 回退重试
nextIndex3. 快速回退优化
| 特性 | Raft | 2PC |
|---|---|---|
| 一致性 | 强一致 | 强一致 |
| 可用性 | 高(只需大多数节点) | 低(需要所有节点或coordinator恢复) |
| 适用场景 | 元数据存储、配置管理 | 分布式事务 |
| Blocking | 非阻塞 | 阻塞 |
| Leader | 自动选举 | 固定coordinator |
| 索引类型 | 读性能 | 写性能 | 范围查询 | 空间占用 | 适用场景 |
|---|---|---|---|---|---|
| B+树 | O(log n) | O(log n) | ✅ 优秀 | 中等 | OLTP、通用场景 |
| Hash索引 | O(1) | O(1) | ❌ 不支持 | 较小 | KV存储、等值查询 |
| LSM-Tree | O(log n) | O(1)* | ✅ 良好 | 较大 | 写密集型、时序数据 |
实践建议:
OLTP场景:优先选择B+树索引
时序数据、日志场景:考虑LSM-Tree
KV存储:可考虑Hash索引
索引使用注意事项:
| 策略 | 负载均衡 | 点查性能 | 范围查询 | 扩展性 | 实现复杂度 |
|---|---|---|---|---|---|
| Round-robin | ⭐⭐⭐⭐⭐ | ❌ 需扫描所有分区 | ❌ 需扫描所有分区 | 简单 | 低 |
| Hash | ⭐⭐⭐⭐ | ✅ O(1)定位 | ❌ 需扫描所有分区 | 重分区困难 | 中 |
| Range | ⭐⭐⭐ | ✅ O(1)定位 | ✅ 可局部扫描 | 需动态调整 | 高 |
实践建议:
消息队列:使用Round-robin
KV存储、缓存:使用Hash分区
关系型数据库:使用Range分区
分区实践要点:
| 方案 | 一致性 | 可用性 | 读性能 | 写性能 | 复杂度 |
|---|---|---|---|---|---|
| 2PC | 强一致 | ⭐⭐ 低 | 低 | 低 | 中 |
| Quorum | 最终一致 | ⭐⭐⭐⭐ 高 | 中 | 中 | 低 |
| Primary Copy (Raft) | 强一致 | ⭐⭐⭐⭐ 高 | 高 | 中 | 高 |
实践建议:
元数据存储、配置中心:使用Raft
大数据存储:使用Quorum
避免使用2PC:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 | 适用场景 |
|---|---|---|---|---|---|
| Read Uncommitted | ✓ | ✓ | ✓ | 最高 | 几乎不用 |
| Read Committed | ✗ | ✓ | ✓ | 高 | 对一致性要求不高 |
| Repeatable Read | ✗ | ✗ | ✓/✗* | 中 | 大多数场景(默认) |
| Serializable | ✗ | ✗ | ✗ | 低 | 强一致性需求 |
*注:MySQL InnoDB在RR级别通过Next-Key Lock解决了幻读
实践建议:
大多数场景:使用Repeatable Read
高并发读多写少:使用Read Committed
金融、支付场景:使用Serializable或Snapshot Isolation
隔离级别使用要点:
本文系统性地介绍了数据库系统的内部实现,包括:
数据库系统是一个复杂的系统,涉及存储、索引、事务、并发控制、分布式等多个方面。理解这些内部原理,有助于我们:
在实际应用中,需要根据具体场景选择合适的技术方案,在一致性、可用性、性能之间做好权衡。