背景说明
流媒体服务不同于传统服务,有着高并发,高负载,高带宽等特性,当 24 小时不间断写录像时,磁盘 IO 瓶颈造成的最明显问题,是读取数据变慢了。
在机械硬盘上测试,PostgreSQL 与 SQLite 在磁盘 IO 高负载的情况下,读取速度几乎无差别,因嵌入式的特性,服务与数据库也没办法分离到多台服务器。
有哪些办法解决这个问题呢? 使用 Redis 缓存当然是一个好办法,但需要维护一份 Redis 实例,为了确保数据库与 Redis 的数据同步,启动时需要将数据库全部内容有结构的缓存到 Redis,启动效率会变慢。当有结构以后,分页查询,数据更新等等,应用层的代码逻辑会更复杂。
所以希望能有一个代码复杂度较低,能够保证数据一致性,简单性,适合嵌入式的缓存方案。
在避免大量改动代码的情况下,从调用上来说缓存还得用数据库,内存数据库,有以下两种解决方案。
- 单内存 DB + 定期持久化到磁盘
- 双 DB (内存 + 磁盘)
前者数据一致性弱,在上一次持久化结束到下一次持久化之前,期间发生崩溃数据就会丢失,每次持久化要全量持久化,每次持久化时会增加磁盘 IO。
后者还是读写数据库,缺点是要写两个数据库,一个内存数据库,一个磁盘数据库,其写性能会略有下降,读性能翻倍提升。
接下来围绕双写 DB 说说实践,核心特点:
- 读只读内存数据库,毫秒级响应
- 写入同时写入内存和磁盘,保持持久化
- 对业务层完全透明,无需修改任何 GORM 代码
- 支持崩溃恢复,启动时从磁盘恢复数据
SQLite
Memory
使用特殊文件名 :memory: 打开内存数据库,执行以下命令,不会打开任何磁盘文件,只会在内存中创建一个新的数据库。
- 一旦数据库连接关闭,该数据库将不复存在
- 打开多个,会创建多个独立的内存数据库
|
|
ATTACH DATABASE
将另一个数据库文件添加到当前数据库连接中,方便数据恢复。
GORM
业务中使用 GORM 库,gorm 获取 ConnPool 处理 SQL,所以自定义的 DualDB 要实现该接口。
- 写入路径: GORM -> QueryContext (磁盘库 + 内存库)
- 查询路径: GORM -> QueryContext(内存库)
- DDL 路径: GORM -> ExecContext(内存库 + 磁盘库)
- 崩溃恢复: ATTACH -> 将结构/数据/索引完整复制到内存DB
|
|
- PrepareContext 在内存库上准备 SQL 语句
- ExecContext 执行 DDL/DML,双写
- QueryContext 主要执行查询(读内存),或智能双写,尤其要注意,含有
RETURNING子句的Create/Update/Delete是由此函数执行,而非ExecContext,需要检测 SQL 前缀判断是否应该双写数据库。 - QueryRowContext 执行单行查询(读内存)
实现事务支持
|
|
- BeginTx 开始双写事务
- Commit 事务提交
- Rollback 事务回滚
结果对比
在磁盘 IO 高负载的环境下,机械硬盘上进行测试,这里对比了三种模式。
- 第一种模式不做任何优化
- 第二种模式是 durlDB,牺牲一定的写性能,换取翻数倍的读取性能
- 第三种模式是采用 SQLite 配置缓存优化,需要同时提升读写性能可以根据 SQLite 官方文档优化
| 模式 | 写入 TPS | 读取 TPS | 写入延迟 | 读取延迟 |
|---|---|---|---|---|
| 模式 1: 默认 | 144.50 | 146.80 | ~4.34s | ~4.26s |
| 模式 2: DualDB | 52.00 | 1046.30 | ~12.42s | ~490.30ms |
| 模式 3: 默认+缓存优化 | 440.10 | 443.00 | ~1.20s | ~1.20s |
DualDB 适合以下场景:
- 读多写少
- 对读取延迟敏感的场景
- 磁盘 IO 成为瓶颈的场景
如果需要同时提升读写性能,可以参考,首次是没有缓存的,高负载情况下,首次读取还是很慢的。
|
|