【整理】互联网服务端技术体系:存储基础之数据存储与索引结构
数据存储系统是软件的基石。
综述入口见:“互联网应用服务端的常用技术思想与机制纲要”
引子
数据存储系统是互联网软件应用的基石。要理解一个数据存储系统,至少要理解三个要素:
- 存储模型:数据是如何组织和存储的?
- 索引结构:如何高效稳定地查找到满足条件的数据集合?
- 开发要点:该数据存储的优缺点、适用场景及如何高效使用?
数据特点与组织
- 问题分析 -> 抽象 -> 提取出关键数据集 -> 数据范式与结构化 -> 数据 Shema -> 一致性与完整性 -> 组织与存取。
- 数据特点:结构化、半结构化、非结构化;记录、文本、二进制流;
- 数据关联:聚合关系(有意义的实体);对应关系(一对一、一对多、多对多);映射关系;泛化与特化(相似与相异);衍生关系(计算与推理)。
- 数据变化:静态不可变、只变更一次、很少变更、频繁变更、瞬时变更很多次。
- 数据操作:插入、查询、更新、删除(CRUD);过滤、排序、分组、聚合(FSGA)。
- 业务驱动:业务 -> 问题 -> 视角 -> 数据 Schema -> 采集数据 -> 处理数据。
存储及索引
有两种主要的数据存储及索引结构:LSM-Tree 和 B-Tree。由于索引结构总是针对数据存储结构而设计和优化的,因此两者是紧密关联的,很难分开来讨论。
LSM-Tree
LSM:Log-Structured Merge-Tree。从 AOF 和 SSTable 演变而来。
AOF
- Append-Only File
- 只添加新记录,不修改或删除已有记录;
- 当前写入文件超出一定大小时,创建新的文件再写入;这些文件称为 Segment Files ;
- 需要压缩和合并 Segment Files,让最终的 AOF 文件体积更小;
- 合并完成后,删除旧的 Segment Files,读请求会转发给最新的 AOF 文件;
- Bitcask 模型: 多个不可变 Segment Files + 一个活跃的可写 Segment File;
- 适合 key 比较少,但 value 更新频繁的场景;适合日志文件场景。
索引结构:
- 需要内存中的 HashMap 来索引指定 key 在磁盘文件上的记录;
- HashMap 索引:key 是记录的 key, value 是记录在磁盘文件上的 offset。
实现细节:
- 文件格式:文本文件更可读,二进制文件会更快。通常格式是“checksums + 数据长度+数据内容”;
- 删除记录:丢弃对应 key 的之前记录,能加快合并过程;
- 故障恢复:在磁盘上保持 AOF 的 HashMap 的快照, Bitcask 可以加速恢复速度;
- 部分写入:Bitcask 包含 checksums, 确保部分写入的脏数据被检测到和忽略掉;
- 并发控制:已有文件是不可变的,支持并发读。
优点:
- 顺序写,写磁盘效率更高;
- 并发控制和恢复更简单,无需考虑已有记录被更新成新值。
缺点:
- 不支持范围查找;
- 有大量 keys 时,内存占用过大,而磁盘实现的 HashMap 的性能会很低(随机 IO 太多)。
SSTable
- Sorted String Table
- keys 是有序的;
- 合并时可以采用归并排序;
- 可以将 keys 分组成一个个有序块,压缩后存放在磁盘文件里,支持范围查找。
SSTable 内存索引:
- 依然是 HashMap 结构;
- 只需包含更少 keys , 比如这些 keys 是一组记录 keys 的前缀集合;
- 一个 Segment File 可以只需要一个 key;
- 可以使用红黑树或 B 树;
- 当内存索引过大时,可以写入磁盘文件 SSTable File;
- 查询记录时,先查询内存索引,找到记录所在的文件位置,再取记录;如果在内存索引里找不到,则依次从最近的 SSTable File 里去查找记录的磁盘索引位置。
优化:
- 如果数据写入了内存索引但还没写入磁盘文件 SSTable File,突然发生故障了,就可能导致索引不完整。可以加一个额外日志,每当写内存索引时,也写一条记录在这个日志里。
优点:
- 支持范围查找;
- 由于是顺序写,写吞吐量很高。
LSM-Tree
- LSM-Tree 基于 SSTable 构建并进行了优化;
- 压缩和合并可以采用 size-tiered 或 leveled;
- 使用 BloomFilter 来定位不存在的 keys ,避免在找不到 key 时在磁盘中搜索过多次数;
B-Tree
- 将记录集分割成固定块或页,一次读写一个块或页; LSM-tree 的记录集分割是可变大小的;
- 多重有序表的链接;
优化:
- WAL:Write-ahead Log ,保证 B 树可靠性。避免在 B 树节点分裂发生故障时,B 树会 corrupted ;
- Copy-on-write:
- B+ 树:内节点只存 key ,不存数据,让树的内节点能存储更多的 keys ,减小树的高度,减少磁盘读写次数;
- 叶子页使用链表连接起来,保持有序。
对比
- LSM 写性能更好,而 B-Tree 读性能更好;
- LSM 压缩文件比 B-Tree 体积更小;
- 对于一个 key 来说, LSM 存在多份拷贝,而 B-Tree 只有一份; B-Tree 能更好地支持事务。
应用
不同的数据应用场景及数据存储查询需求使用与之适配的工具来处理。
DB
存储结构及索引
- 存储结构:表、记录、(固定)字段列、数据项;
- 索引结构:B+ Tree ;HashMap ;
- 适用场景:适合固定 Schema 的结构化方式来存储大量结构化数据。大规模结构化数据存取的优先选择;
- 主要优势:数据存储的规范性、完整性;事务与持久化。插入和查询的效率高,但更新的代价较高;
开发要点
- 数据模型与表设计:体现在规范性和完整性上;
- 索引设计与 SQL 语句优化:主要是性能考量,可见:“【整理】互联网服务端技术体系:高性能之数据库索引”;
- 事务:数据一致性保障,可见:“【整理】互联网服务端技术体系:存储抽象之事务概念与实现”;
- 并发:性能考量及避免死锁;
- 连接配置优化:高并发支持与稳定性保障,主要包括池大小和超时设置。
表设计
- 主键与唯一键的设置;
- 列的类型设置:主键 ID 用 Long 型;字符串的 CHAR 或 VARCHAR;长字符串的 text 或 blob;日期用时间戳或字符串;是否可以为 NULL;是否有默认值;注释;
- 数据库范式:遵循数据库范式原则,在规范性与冗余之间达成一个平衡点;
- 分库分表:大数据量表拆小表;不同领域业务的字段分拆为不同的业务表;主辅分开,大扩展字段使用扩展表,与主表分开存储;
- 场景走查:论证模型的可行性和完整性。
避免死锁
- 更新同一个实体的两个不同表时,获取行锁的操作在时间上不要过于接近,且只能按照同一次序获取;
- 对于所使用到的全部数据,一次性加锁完毕再来更新;
- 避免长耗时的更新操作及含有用户交互的事务;
- 将大事务拆分为小事务;
- 读写分离,将查询数据库与更新数据库分开。
- 外键加索引;
- 锁等待图,及早检测死锁的发生并放弃;
Redis
- 存储结构及索引: K-V 映射 ;V 可以是各种基本数据及容器类型,比如字符串、位图、集合、有序列表、对象;
- 适用场景:内存数据结构的规模化,适合核心数据子集的频繁快速读写。比如应用数据缓存、分布式锁、数据聚合/排名、关联关系、任务队列等;
- 主要优势:内存读写,性能高、集中化,更新效率高;
- 开发要点:需要能够灵活使用数据结构(字符串、列表、集合、散列、有序集合)及组合来实现业务功能;
- 注意事项:需要限制数据集的最大值及遏制快速膨胀,处理好过期清除问题。选择适当的缓存策略和缓存读写策略,避免缓存雪崩/击穿/穿透问题/一致性问题。
HBase
存储模型
- 数据存储及索引:LSM ;
- 适用场景:适合大规模非结构化数据的存储读写;
- 部署架构:Master/Slave,LSM, [ Master, RegionServer, HRegion, HDFS,Zookeeper ] ;
- 数据存储模型 :[ Rowkey, ColumnFamily, ColumnName, Value, Timestamp ] (逻辑) ; [ Store, MemStore, StoreFile, HFile, HLog ] (物理)。一个 HServer 包含多个 HRegion, 一个 HRegion 包含 一个 HLog, 多个 Store 。 一个 Store 包含 一个 MemStore ,多个 StoreFile, 多个 HFile , HFile 存储在 Hadoop 上。写操作先写入memstore,当memstore中的数据达到某个阈值时,RegionServer会启动flashcache进程写入storefile,每次写入形成单独的一个storefile。
- 列写入:动态列写入,无需事先声明;每个列可以存储多个值及对应的时间戳;列写入的过滤机制:对于新的列值 ,若其 Timestamp 比现有的小,则数据不会写入;
查询与优化
- 根据 rowkey 查询 HRegion 地址 (zk 缓存以及 hbase:meta 表);
- RegionServer 处理读请求。Scanner 对象,最小堆。
- 客户端读优化: get 请求 - 启动时表预热,rowkey 均衡,适量批量 get ,指定列族; scan 请求 - 改造为 batchGet ,设置合适的 startKey 和 endKey ,设置 caching 数量。 服务端优化:读缓存配置、本地化率,SSD 盘,短路读等;
开发要点
- Rowkey 设计、预分区及 region 数据分布平衡;
- 启动预热表、超时设置、主备设置;
- BatchGet 替代 Scan ;
ES
存储结构及索引
- 存储结构:文档、JSON字符串;无法部分刷新。只能写入新的文档,然后删除旧的文档,定期刷新清理;
- 索引结构:倒排索引及优化;
- 适用场景:大规模文本数据搜索;
- 主要优势:文本搜索。
开发要点
- 四种查询: query and fetch, query then fetch, DFS query and fetch, DFS query then fetch。 ES 查询数据实际上包括两个步骤:查询到符合搜索条件的 DOC ID 列表(query),根据 DOC ID 列表获取 DOC 数据(fetch);and 就是把两个步骤合成一个步骤执行,可以节省网络请求开销(如果数据量不大),then 则是先查询后取数据(减少拿到不必要的数据量);DFS 是为了让结果更加精确;
- 深分页问题:[ from,size ] 需要从 N 个分片中各取出 (from+size) 条数据,总共 N*(from+size) 条数据。from 很大时,要取出非常多的数据,才能返回指定的少量数据。 深分页方案:scroll 与 search_after 及对比。search_after 无状态,指定唯一标识及排序,可以实时查下一页,不支持跳页查询,集群资源消耗小;scroll 有状态,取一段时间的快照,新写入数据查不到,scroll context 开销大,集群资源消耗大;
- 精确匹配常用数据:使用 Filter 缓存;
- 查询大量数据:避免使用深分页,使用 search_after 或 scan 来实现。
倒排索引及优化
- 更新操作:ES 对文档的操作是在分片的单位内进行的,实际上是针对倒排索引的操作。倒排索引是不可变的,因此可以放在内核文件缓冲区里支持并发读。ES 更新文档必须重建索引,而不是直接更新现有索引。为了支持高效更新索引,倒排索引的搜索结构是一个 commit point 文件指明的待写入磁盘的 Segment 列表 + in-memory indexing buffer 。Segment 可以看做是 ES 的可搜索最小单位。新文档会先放在 in-memory indexing buffer 里。当文档更新时,新版本的文档会移动到 commit point 里,而老版本的文档会移动到 .del 文件里异步删除。ES 通过 fsync 操作将 Segment 写入磁盘进行持久化。由于 ES 可以直接打开处于文件缓冲区的 commit point 文件中的 Segment 进行查询(默认 1s 刷新),使得查询不必写入磁盘后才能查询到,从而做到准实时。
- 优化技术:倒排索引 - trie 前缀树 - Skip List - 增量编码。
- 倒排索引:构建关键字词典,存储关键字及其文档编号、文档位置;
- trie 前缀树:为关键字词典构建词典索引(term index)以 FST 放在内存里,减少磁盘 random access 次数,节省空间;
- SkipList:针对联合搜索设计,有序 DOC ID 列表求交集;
- 增量编码:针对 posting list 的压缩技巧。
增量编码
- STEP1:获取到 DOC ID 列表后,先进行预排序;
- STEP2:对排序后的 DOC ID 进行增量编码存储,求出每个 DOC ID 针对前一个 DOC ID 的增量,只存储增量值;
- STEP3:将增量值对 65535 取商和余,分割成多个组(商数相同的放在一个组),对于每组增量值,采用合适的位数(适配每组增量值中的最大值),转换成位进行存储。
数据同步
DBtoHBase
- 基本准则:尽可能保持源数据拷贝,在源数据的基础上更新算法;
- HBase 列:可以是“表名:字段名”或”表名:字段名:主键ID值“。
DBtoES
由于 ES 的记录是一个整体 JSON 串,不能部分刷新,在多表数据同步 ES 进行并发更新时,会出现乱序问题。
多表同步 ES 的问题及解决方案见:“多表同步 ES 的问题”