万字长文详解降本增效利器 PikiwiDB(Pika) 混合存储原理
1 混合存储
2023 年 11 月 PikiwiDB 社区发布了 PikiwiDB(Pika) v3.5.2【下文简称 Pika】版本。
在本版本更新中,我们引入了一个关键特性:通过在 Pika 的命令处理层集成 Redis 缓存,对冷数据与热数据进行了分离,在性能和成本之间达成了平衡,实现了混合存储。本文旨在深入探讨这一特性的架构设计与核心思想,期待与各位同行共同探讨。
在大型键值(kv)存储系统中,用户访问的数据通常呈现明显的冷热分布特性。所谓热数据,即那些被频繁访问的数据;而冷数据则相反,它们被访问的频率极低。为了提高数据访问的效率,降低读取耗时,关键在于如何让热数据更多地驻留在内存层,减少不必要的磁盘I/O操作。
为了实现这一目标,我们借鉴了 Redis 的缓存机制,并将其集成到 Pika 的命令处理层中。当用户请求到达时,Pika 首先检查 Redis 缓存中是否存在所需数据。如果数据存在于缓存中(即热数据),则直接返回给用户,无需再访问磁盘层;如果数据不存在于缓存中(即冷数据),则再去 RocksDB 检索数据,并将其加入缓存层以供后续访问。通过这种方式,我们不仅能够显著提高热数据的访问速度,还能够逐步将冷数据转化为热数据,从而优化整个系统的性能。
2 方案选择
为了实现混合存储的缓存层,存在两种方案:
-
- 引入 cache 库: 直接使用第三方 cache 库,如 Memcached。
-
- 静态编译 cache 库: 将 cache 的静态编译库引入 Pika,将其作为 Pika 的一个小模块进行维护。
方案对比:
方案 | 优点 | 缺点 |
---|---|---|
引入 cache 库 | 易于使用 | Pika 维护的组件增多 |
静态编译 cache 库 | Pika 组件不变,可定制化 | 需要对 Pika 服务进行定制化 |
综合考虑兼容性问题和可持久化维护问题,最终选择了使用 Redis 库进行实现,并采用了第二种方案:
- 可定制化: 静态编译 cache 库可以根据 Pika 的需求进行定制化,提高性能和稳定性。
- 组件不变: Pika 维护的组件数量保持不变,降低维护成本。
3 冷热分离
Pika 的混合存储架构并非在所有读写流程中都更新缓存,而是针对以下几种情况进行缓存更新:
-
- 所有读命令: 由于读操作表明数据被访问,因此将读取到的 key 更新至缓存中,以提升后续访问的效率。
-
- 写命令中的 key 存在: 对于写入操作,如果 key 已经存在,则更新缓存中的数据,确保缓存与 RocksDB 中的数据保持一致。
-
- 写命令中的新 key: 对于写入操作,如果 key 不存在,则不更新缓存,因为此时无法确定该数据是否会被频繁访问。
通过上述策略,Pika 能够有效将热数据加载到缓存中,实现冷热数据分离,从而显著提高读操作的性能。
4 缓存架构
缓存层架构图
Pika 混合存储架构采用多 cache 设计,主要基于以下考虑:
-
- Redis 性能瓶颈: Redis 的内存使用量超过一定阈值【如 16GiB 】时,其性能会明显下降。
-
- 数据分散存储: 通过多 cache 将 key 分散存储,可以有效缓解单一 cache 的性能压力。
Pika 采用 CRC32 算法对 key 进行散列:
-
- 计算 key 的 CRC32 值。
-
- 将 CRC32 值映射到一个 cache 编号。
-
- 将 key 存储在对应的 cache 中。
Pika 第二代存储引擎 Blackwidow 支持五种数据结构,且每种数据结构都拥有独立的 RocksDB 实例,因此不同类型 key 可能出现重复。不像 Blackwidow 那样,Pika 第三代存储引擎 Floyd 为了更进一步向 Redis 接口靠拢,不再支持一个 key 写多种数据类型,对重复 key 进行覆盖处理。为了兼容这两个存储引擎,Pika 需要对 key 进行前缀处理,以避免数据冲突。
-
- 加前缀: 读写 cache 时,所有 key 都添加前缀,前缀代表数据类型。
-
- 访问 DB: 访问 RocksDB 时,不添加任何前缀。
/*
* key type
*/
const char PIKA_KEY_TYPE_KV = 'k';
const char PIKA_KEY_TYPE_HASH = 'h';
const char PIKA_KEY_TYPE_LIST = 'l';
const char PIKA_KEY_TYPE_SET = 's';
const char PIKA_KEY_TYPE_ZSET = 'z';
5 读写命令
命令处理流程
std::shared_ptr<Cmd> PikaClientConn::DoCmd(const PikaCmdArgsType& argv, const std::string& opt, const std::shared_ptr<std::string>& resp_ptr)
Pika 实例的命令处理入口为 PikaClientConn::DoCmd 方法。在引入混合存储架构后,为了保证缓存与数据的一致性,需要对命令处理流程进行调整。
- 主实例的写操作在 PikaClientConn::DoCmd 方法中执行。
- 从实例的写操作在消费完 binlog 后写入 DB,并同时更新缓存。
命令处理基类
每个命令都继承自基类,并实现基类中的几个虚函数,根据命令的类型和标记确定是否需要处理缓存和数据。
Cmd:Do 命令处理
Cmd:Do
命令需要实现以下三个虚函数:
-
- ReadCache: 只存在于所有读命令中,用于从缓存中读取数据。
-
- DoThroughDB: 存在于所有读写命令中,用于从 DB 中读取或写入数据。
-
- DoUpdateCache: 存在于所有读写流程中,用于更新缓存。
- 每次读命令都会将读取到的数据更新至缓存。
- 写命令需要根据 key 是否存在于缓存中决定是否更新缓存,以确保缓存中只包含热数据。