深入浅出 Cache
章节
- ① 什么是 Cache? Cache 的目标?
- ② Caching 住哪些内容?
- ③ 我们想要的 Cache 产品
- ④ Cache 使用方式
- ⑤ 对于总体系统的提高
- ⑥ 关于 Sharding
- ⑦ Cache 痛点和关注点
- ⑧ 我们用的 Cache 的产品
- ⑨ 我们的一些实践
① 什么是 Cache? Cache 的目标?
- 在说这个之前我们先看下典型 Web 2.0 的一些架构演变 (这里不用”演进”). 从简单的到复杂的通用架构.
- 首先, 诚然说 Cache 在互联网公司里, 是一个好东西. Cache 化, 可以显著地提高应用程序的性能和便于提供应用程序的伸缩性 (可以消除不必要请求落到外在的不频繁改变数据的 DataSource 上). 那么 Cache 化目的非常明显, 就是有且只有一个: 提高应用程序的性能.
- 再者, Cache 化, 以 in-memory 为组织形式, 作为外部的持久化系统的数据的副本 (可能数据结构不同), 仅仅为了提高性能. 那么 Cache 化的数据应当是短暂停留在 Distributed Cache 中 — 它们可能(可以) 随时的消失 (即使断电不保证立马就有数据 - 这一点类似 CPU 的 L1/L2 Cache), 那么应用在用到 Cache 时候仅当 Cache 系统可用时候使用不应当完全依赖于 Cache 数据 — 就是说在 Distributed Cache 中个别的 Cache 实例失效, 那么 DataSource(持久化) 可以临时性完成数据被访问的工作.
- 最后, 我们可以假定如果各种 DataSource 自有的系统性能非常高, 那么 Cache 所能解决的领域就变得非常的少.
② Caching 住哪些内容?
- 能够提高系统整体命中率 + 提高性能的一切数据, 均放入 Distributed Cache 是非常合适的.
③ 我们想要的 Cache 产品
从上面的目标和定位推理看一款 Cache 产品应当满足以下需求 (不仅仅有):
- 极致的性能, 表现在极低的延迟, 甚至从 ms 到 us 响应
- 极高的吞吐量, 可以应对大促 / 大流量业务场景
- 良好的扩展性, 方便扩容, 具备基本的分布式特点而不是单机
- 在扩容 / 缩容的时候, 已有的节点影响 (发生迁移) 的成本尽可能低
- 节点的基本的高可用 (或者部署上可以没有)
- 基本的监控, 进程级别和实例级别等都有关键性的指标
④ Cache 使用方式
说到 Cache 使用方式, 必不可少的会与数据库 (甚至是具备 ACID 的 RDBMS) 或者普通存储系统对比.
- 简单的而言. 即使 Cache 有了持久化, 但市面上的 Cache 产品 (Redis 还是其它) 都不具备良好的高可靠的持久化特性(无论是 RDB 还是 AOF, 还是 AOF+RDB), 持久化的可靠性都不如 MySQL. 注: 这里不深入 Redis 原理和源码和 OS 文件存储内容.
而使用方式有以下三种:
- 懒汉式 (读时触发)
- 饥饿式 (写时触发)
- 定期刷新
懒汉式 (读时触发)
这是比较多的场景会使用到. 就是先查询第一数据源里的数据, 然后把相关的数据写入 Cache. 以下部分代码:
Java (Laziness)
Jedis cache = new Jedis();
String inCache = cache.get("100");
if (null == inCache) {
JdbcTemplate jdbcTemplate = new JdbcTemplate();
User user = jdbcTemplate.queryForObject("SELECT UserName, Salary FROM User WHERE ID = 100",
new RowMapper<User>() {
@Override
public User mapRow(ResultSet rs, int rowNum) throws SQLException {
return null;
}
});
if (null != user) {
cache.set(String.valueOf(user.getId()), user.toString()); // 可以异步
}
cache.close();
}
好处和坏处:
- 不太好的是: 大多数的数据可能不再被高频度访问. 如果第一次访问不命中就有另外多余的副作用.
- 比较好的是: 保证数据在 Cache 里. 适用于大多数的场景.
饥饿式 (写时触发)
Java (Impatience)
User user = new User();
JdbcTemplate jdbcTemplate = new JdbcTemplate();
int affectedRows = jdbcTemplate.update("UPDATE User SET Phone = ? WHERE ID = 100 LIMIT 1",
new Object[] { 198 });
cache.set(String.valueOf(user.getId()), user.toString()); // 可以异步
好处和坏处:
- 比较好的是: 这种写比例不高数据, 能保证数据比较新.
分析下重要的几条, 关于 "懒汉式" 和 "饥饿式":
- 饥饿式总保持数据较新
- 分别存在了写失误 / 读失误
- 单一方式的使用都将使 Miss 概率增加
以上两种各有优缺点, 因此我们将两种结合一下 (追加一个 TTL):
Java (Combo : Laziness && Impatience)
cache.setex(String.valueOf(user.getId()), 300, user.toString()); // TTL, 可以异步
定期刷新
常见场景, 有如下几点
- 周期性的跑数据的任务
- 适合 Top-N 列表数据
- 适合不要求绝对实时性数据
- …
⑤ 对于总体系统的提高
以下看看命中率如何影响总体系统?
为了简化公式计算, 以下做一些假定.
-
场景一: 我们假定, HTTP QPS 有 10,000, 没有使用 Cache(变相地假定 Miss100%), RDBMS 是读 3 ms/query , Cache 是 1 ms/query. 那么理想下 10,000 个 Query 总耗时: 3 ms/query * 10,000query = 30,000 ms
如果我们用了以上 2 者结合的方式
假定是 90% 命中率, 那么理想下 10,000 个 Query 总耗时: 3 ms/query * 1,000query + 1 ms/query * 9,000query = 12,000 ms.
假定是 70% 命中率, 那么理想下 10,000 个 Query 总耗时: 3 ms/query * 3,000 query + 1 ms/query * 7,000query = 16,000 ms. -
场景二: 我们假定, HTTP QPS 有 10,000, 没有使用 Cache(变相地假定 Miss100%), RDBMS 是 读: 写 是 8 : 2 . 读 3 ms/query, 写 5 ms / query, Cache 是 1 ms/query. 那么理想下 10,000 个 Query 总耗时: 3 ms / query * 8,000 query + 5 ms / query * 2000 query = 34,000 ms .
如果我们用了以上 2 者结合的方式, 假定新数据写入后才有读的操作, 那么命中率可能为 100%, 那么理想下 10,000 个 Query 总耗时: 1 ms/query * 8,000query + 5 ms/query * 2000 query = 18,000 ms. 差一些命中率可能为 90%, 那么理想下 10,000 个 Query 总耗时: 1 ms/query * ( 8,000query90%) + 3 ms/query * ( 8,000query10%) + 5 ms/query * 2000 query = 19,600 ms. 再差一些命中率可能为 70%, 那么理想下 10,000 个 Query 总耗时: 1 ms/query * ( 8,000query70%) + 3 ms/query * ( 8,000query30%) + 5 ms/query * 2000 query = 22,800 ms.
可以看到 22,800ms / 19,600ms = 117%, 那么有 17% 的性能损失.
以下看看 Cache 高可用下如何影响总体系统?
为了简化公式计算. 我们假定 Cache 依然是提高性能使用, 就是说数据源不是 Cache 层的.
- 场景一: 如上 Web2.0 架构里, 访问 Cache 一层, 和访问 MySQL 一层. 在压力可接受的情况下.
假定 Cache 集群可用性是 99%, MySQL 可用性是 99%. 即使集群里挂了一个 Cache 实例, 那么总体系统的可用性: (1 - (1-99%)(1-99%) ) = 99.99% .
假定 Cache 集群可用性是 99%, 共有 10 个实例. MySQL 可用性是 98%, MySQL 可以承受 3 个 Cache 实例带来的压力, 即使集群里挂了两个 Cache 实例, 那么总体系统的可用性: (1 - (1-99%)(1-99%)*(1-98%) ) = 99.9998% - 场景二: 访问 Cache 一层, 但因为某种因素不再访问 MySQL 一层. 那么总体系统的可用性: (1 - 1% - 1%) = 98%
- 场景三: 不算 Web2.0 的架构里, 访问 Cache 一层, 和访问 MySQL 一层, 和不访问 MySQL 一层, 那么总体系统的可用性是多少呢? — 答案留给读者
对比场景一和场景二, 在增加正常的系统处理下 (就是多几行代码), 我们就可以提高极大的总体系统的可用性. - (这里声明下: 任何一个系统, 不可能有 100% 的可用性, 包括 Google 也一样, 我们能做的就是多做几个 9 的可用性)
⑥ 关于 Sharding
算法有以下常见的两种比较:
- Hashing
- Consistent Hashing (using virtual nodes)
- servers = [‘cache-server1.yuozan.com:6379’, ‘cache-server2.youzan.com:6379’];
- server_index = hash(key) % servers.length;
server = servers[server_index;
算法描述
- 第一种 Hashing 方式, 一旦需要扩容一个或者下线一个, 那么会导致大量的 keys 重分配: = (old_node_count/new_node_count), 就是说 3 台 server 扩充到 4 台 server 时候, 3/4 = 75% 的 keys 都受到影响.
- 第二种 Consistent Hashing 方式, 一旦需要扩容一个或者下线一个, 那么仅有将近 (1 - (old_node_count/new_node_count) )比例的 keys 受到影响, 就是说 3 台 server 扩充到 4 台 server 时候, (1 - 3/4) = 25% 的 keys 都受到影响. 这样相比上一种受到的影响降低了 50%. 这将是更好的方式.
Consistent Hashing 简化算法流程的描述:
- 将 keys 和 servers 都进行看成一个 ring(常被称为 continuum)
- 将 keys 和 servers 的 hash 值分隔成多个的 slots
- 将 servers 的 virtual nodes 按照顺时针顺序分别映射到 slots 上
- 将 key 进行 hash 按照顺时针顺序查找最近的一个 virtual node
⑦ Cache 痛点和关注点
公司以前业务刚起来, 用的 Redis 当作 Cache, 大家知道 Redis 是单机版本 - 没有 Sharding. 由于业务起来, 单机版本对于某个业务来说, 一旦扩容或是挂了那个业务的所有流量都挂了, 当时只做到了垂直分片 (Vertical Partition), 而为了快速解决这一问题, 我们必须引入 DistributedCache, 希望它简单的好 (因为我们只用来做 Cache), 甚至目标都不想让 Redis 做持久化数据.
⑧ 我们用的 Cache 的产品
2015 年为了业务技术改造, 并能快速的上线. 我们调查了 Twemproxy Codis. 考虑到我们技术投入. 同时对 Codis 做了相应的测试, 最终使用 Codis 作为 Cache 的产品来使用. (性能可以看看 Codis 官方的对比) 另外我们结合自己 PHP 的业务需要, 做了 PHP 和本地部署 Proxy 的方式来基准测试.
Codis 提供的扩容时的迁移采用了向新老的 Server 双写的模式, 在迁移数据到达了 100% 的量时候会有一定的极短的锁时间 (这有优势也有劣势), 我们和 Redis 官方一样不建议开启 AOF.
从目前一年多的使用和运维经验看, Codis 已经满足我们当下的业务需求. 对于双 11 等类似的大促峰值, 我们可以看到 Codis 单纯当作 Cache 来使用的可靠性是比 MySQL 高的, 也就是说: 如果假定在高峰值下, 即便是 Cache 会挂了, 并将流量打到了 MySQL 集群上, 那么对于外网的业务而言系统一样是不可用的. 那么只要保证不出现 Cache 整个集群挂了 - 只要保证一两个实例 (极点比例) 挂了, 那么流量分散到 MySQL 集群上后大促业务依旧保持可用.
⑨ 我们的一些实践
- 着重在 Codis-Server 上的 Redis 配置, 在运维上尽可能提高 Server 一侧的性能: 绑定单核至每个 Redis 进程 + 去除持久化 (目标: 无 Slave 节点) + 每个 Server 进程实例的内存大小尽可能的小 (控制在 2.5GiB 以内)
- 针对 PHP-FPM 模式下, 用 Codis-Proxy 当做 PHP 的 LocalAgent 直接部署同机上: 提高稳定性 + 降低延迟 (Latency)
- 我们针对 Namespace 多业务共用集群问题: 按照约定取不用的业务 / 应用名称. 另外共用集群在 sharding 情况下带来的实例级别的复用带来的命中率变化和 sharding 均衡性变化, 感兴趣的读者可以自行计算下
(这一次就说这么多, 谢谢.)