为了提高系统吞吐量,我们经常在业务架构中引入缓存层。

缓存通常使用 Redis / Memcached 等高性能内存缓存来实现, 本文以 Redis 为例讨论缓存应用中面临的一些问题。

缓存穿透

为了避免无效数据占用缓存,我们通常不会在缓存中存储空对象,但这种策略会造成缓存穿透问题。

若要查询的数据不存在,那么当然不可能从缓存中查到这个数据,按照缓存失效即访问数据库的逻辑,所有对不存在数据的查询都会到达数据库,这种现象称作缓存穿透。

为了减少无意义的数据库访问,我们可以缓存表示数据不存在的占位符。

与访问一个从未存在过的数据相比访问已删除数据的概率较高, 因此删除数据时应在缓存中放置表示已被删除的占位符。

集合式缓存

Redis 提供了 List、Hash、Set 和 SortedSet 等数据结构,我们将其称为集合式缓存。

集合式缓存通常更新的逻辑较为复杂(或者难以保证一致性)而重建逻辑较为简单,但重建缓存时也可能带来很大的数据库压力。

计数器式缓存同样具有更新逻辑复杂、重建简单但重建缓存时数据库压力大的特点,因此作者也将其归入集合式缓存。计数器的复杂度在对象状态机复杂时尤为明显,如计数某个用户公开文章数和全部文章数。

以文章的评论列表为例,当 Redis 缓存中评论列表为空时,可能有两种原因:

  • 缓存失效
  • 确实没有评论

若当发布评论后试图更新缓存时发现缓存中没有评论列表,我们需要考虑是缓存失效还是原来确实没有评论。不要直接使用 LPUSH 或 ZADD 指令插入评论。

集合式缓存中元素应为不可变的对象或对象ID。仍以评论列表为例,若在 List 或 SortedSet 中直接存储序列化后的评论对象,则只有知道对象的全部字段才能定位该评论。在修改评论后,我们难以获得原评论的内容定位或修改的难度较高。若某条评论存在于多个集合式缓存中,则需要多处修改。

此外,完整的评论对象字节数远大于ID, 在需要多处存储时使用ID可以节省大量内存。

热点数据缓存

在实际业务中我们常常需要处理热点数据缓存失效问题。热点数据的并发读取量很大,一旦发生缓存失效可能会有大量线程访问数据库,可能造成响应变慢甚至数据库宕机等严重后果。

一些场景下可能出现频繁写入的热点数据,使用更新缓存的策略通常不会产生问题。若我们选择了删除过期缓存的策略进行更新,因为热点数据更新非常迅速导致频繁地删除缓存,进一步产生大量缓存失效错误。若采用了先删除缓存后更新数据库的策略,大量读请求非常可能将过时数据写入缓存中造成并发错误。

若热点数据为 Set 或 SortedSet 等集合式缓存,我们可能无法使用一条原子性指令完成整个重建操作,因此需要考虑保证重建过程的线程安全性。

根据对热点数据一致性要求的不同,我们有两套策略。

使用锁保证高一致性

对于高一致性要求的场景我们可以使用分布式锁服务。读请求应获得读锁后才能访问数据,写请求应获得写锁后才能更新数据。

当发生缓存失效的情况时,分布式锁服务会保证有且只有一个读线程获得写锁并完成缓存重建工作,其它读线程因无法获得锁而被堵塞,直到缓存重建完成。这种方法避免了大量线程重复执行缓存重建工作造成数据库压力,但是无法避免响应变慢。

在单例模式中多线程同时调用 getInstance() 方法可能会导致对象重复创建,使用锁进行缓存重建存在着类似的问题。线程A发现缓存失效于是获取写锁进行重建工作,线程B在重建完成前访问缓存仍然出现缓存失效,于是线程B尝试获取写锁。由于写锁被线程A持有,线程B会被阻塞直到重建完成才能得到写锁。因为缓存已被重建,若线程B继续重建缓存则会导致无意义的开销。

使用单例模式中我们熟悉的 Check-Lock-Check 策略即可解决这个问题:

try {
读取缓存
加读锁
} finally {
释放读锁
}
if (缓存失效) {
try {
加写锁
读取缓存
if (缓存失效) {
重建缓存
}
} finally {
释放写锁
}
}

因为有写锁保护我们无需担心重建缓存时的线程安全问题。

乐观策略

当热点数据的缓存失效时,我们可以先使用 placeholder 占位然后进行缓存重建工作。其它线程读取到缓存中的 placeholder 会返回空结果而不会访问数据库,同时也避免了大量线程阻塞可能造成的不良后果。

placeholder 不能保证只有一个线程访问数据库。当线程A写入 placeholder 时,线程B可能已经发生了缓存失效进入了重建流程。

若我们无法保证重建过程的原子性,则可以在临时键上完成重建操作,然后使用 Rename 命令原子性替换掉正式键开放给所有线程。

Rename

虽然 Redis 命令都是原子性的但我们常常会遇到单个命令无法完成的操作,除了使用分布式锁来保证复杂过程的线程安全外,一些场景下我们可以使用 rename 命令来降低开销。

典型的一个场景是上文提到的,无法保证缓存重建或更新操作的原子性时可以在当前线程私有的临时键上完成操作,然后使用 Rename 命令原子性替换掉正式键开放给所有线程。

另一个常见的场景是将脏数据放入 Set 或 Hash 中,使用 SSCAN 或 HSCAN 命令进行异步更新。SSCAN 命令只保证在遍历开始到结束整个过程中一直存在于数据集中的键至少会被返回一次,若遍历的同时添加新数据则可能造成重复或遗漏的情况。

我们可以将脏数据集 rename 到异步线程私有的临时键上,异步线程在遍历私有脏数据集的同时,其它线程仍然可以向线上脏数据集添加数据。

临时键的生成

在集群环境中,可能仅支持相同 Slot 下的 RENAME 和 RENAMENX 命令。因此, 我们可以使用 HashKey 机制保证临时键和原键在同一个Slot中。

若原键为 "original" 我们则可以生成临时键为 "{original}-1", 花括号表示仅由花括号内部的子串进行哈希来决定 Slot, "{original}-1" 一定会与 "original" 处于相同 Slot 中。

使用临时键的目的是为了单线程的进行操作避免并发问题,因此务必检查临时键是否已被其它线程占用。

临时键有两种生成策略:

  • 原键加随机值: 如 "{original}-kGi3X1", 这种方法的优点是随机键冲突的概率较小,但是难以扫描库中有哪些临时键
  • 原键加计数器: 如 "{original}-1"、"{original}-2", 这种方法的优点是容易扫描库中的临时键,但是冲突的概率较高。

在检测临时键不存在后就使用是不安全的,在线程A检测到临时键可用到实际使用临时键之间,其它线程检测同一个临时键时也会认为它可用。

为了避免临时键冲突,我们可以在使用前先尝试设置一个占位符。如,在使用 "{original}-1" 前先执行 "SETNX {original}-1-lock" 若设置成功则可以安全地使用 "{original}-1"。这种做法实际上是加了一个简单的分布式锁。

在更新或重建缓存时应使用加随机值的方法以尽量减少冲突。在遍历脏数据时应使用加计数器的方法,我们可以根据计数器来搜索未被释放的临时键,从而继续被中断的遍历过程。

SortedSet

SortedSet 作为 Redis 中唯一的可排序和可范围查找的数据结构可以进行一些比较灵活的应用。

延时队列

在对一致性没有较高要求的场景可以使用 SortedSet 充当延时队列,将消息的内容作为 member, 预定执行时间的UNIX时间戳作为 score。

调用 ZRANGEBYSCORE 方法轮询预定执行时间早于当前时间的消息并发送给 Msg Consumer 处理。

127.0.0.1:6379> ZADD DelayQueue 155472822 msg
(integer) 1
127.0.0.1:6379> ZRANGEBYSCORE DelayQueue 0 1554728933 WITHSCORES
1) "msg"
2) "1554728822"

必要时可以选用富类型 Java 客户端 Redisson 提供的 RDelayedQueue, 它实现了更完善的延时队列。

由于 Redis 持久化机制等原因,任何基于 Redis 的队列都不可能提供高一致性的服务。

请勿在高一致性要求的业务场景下使用 Redis 做消息队列

滑动窗口

在如热搜或限流之类的业务场景中我们需要快速查询过去一小时内被搜索最多的关键词。

与延时队列类似,将关键词作为 SortedSet 的 member, 发生的UNIX时间戳作为 score。

使用 ZRANGEBYSCORE 命令查询某个时间段内发生的事件, ZREMRANGEBYSCORE 命令移除过旧的数据。

一些常识

阅读本文的读者应有一定的 Redis 缓存使用经验,因此一些基本常识放在最后以尽量避免浪费读者的时间。

  1. IO操作的耗时通常远高于CPU计算,尽量使用 MGET 等批量命令或 Pipeline 机制来减少 IO 时间,切勿循环进行 Redis 读写等IO操作
  2. Redis 使用IO复用模型内核单线程模式,保证命令执行原子性和串行性。(至写作时 Redis 4.0 版本仍是如此,此后很可能引入多线程内核)
  3. Redis 的RDB和AOF都采用异步持久化的模式,无法保证Redis崩溃后完全不丢失数据。 因此请勿将Redis用于一致性要求较高的业务场景。

Redis 缓存应用实战的更多相关文章

  1. spring boot 学习(十四)SpringBoot+Redis+SpringSession缓存之实战

    SpringBoot + Redis +SpringSession 缓存之实战 前言 前几天,从师兄那儿了解到EhCache是进程内的缓存框架,虽然它已经提供了集群环境下的缓存同步策略,这种同步仍然需 ...

  2. SpringBoot微服务电商项目开发实战 --- Redis缓存雪崩、缓存穿透、缓存击穿防范

    最近已经推出了好几篇SpringBoot+Dubbo+Redis+Kafka实现电商的文章,今天再次回到分布式微服务项目中来,在开始写今天的系列五文章之前,我先回顾下前面的内容. 系列(一):主要说了 ...

  3. Canal 实战 | 第一篇:SpringBoot 整合 Canal + RabbitMQ 实现监听 MySQL 数据库同步更新 Redis 缓存

    一. Canal 简介 canal [kə'næl],译意为水道/管道/沟渠,主要用途是基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费 早期阿里巴巴因为杭州和美国双机房部署,存在跨机房同 ...

  4. Redis作为缓存:实战自我总结(转载)

    转载:[http://www.tuicool.com/articles/zayY7v]   redis缓存服务器笔记 redis是一个高性能的key-value存储系统,能够作为缓存框架和队列.但是由 ...

  5. Redis缓存实战教程

    目录 Redis缓存 使用缓存Redis解决首页并发问题 1.缓存使用的简单设计 2.Redis的整合步骤 A 将Redis整合到项目中(Redis+Spring) B 设计一个数据存储策越 3.Re ...

  6. 分布式二级缓存组件实战(Redis+Caffeine实现)

    前言 在生产中已有实践,本组件仅做个人学习交流分享使用.github:https://github.com/axinSoochow/redis-caffeine-cache-starter 个人水平有 ...

  7. Java 使用Redis缓存工具的图文详细方法

    开始在 Java 中使用 Redis 前, 我们需要确保已经安装了 redis 服务及 Java redis 驱动,且你的机器上能正常使用 Java. (1)Java的安装配置可以参考我们的 Java ...

  8. ABP入门系列(13)——Redis缓存用起来

    ABP入门系列目录--学习Abp框架之实操演练 源码路径:Github-LearningMpaAbp 1. 引言 创建任务时我们需要指定分配给谁,Demo中我们使用一个下拉列表用来显示当前系统的所有用 ...

  9. java亿级流量电商详情页系统的大型高并发与高可用缓存架构实战视频教程

    亿级流量电商详情页系统的大型高并发与高可用缓存架构实战 完整高清含源码,需要课程的联系QQ:2608609000 1[免费观看]课程介绍以及高并发高可用复杂系统中的缓存架构有哪些东西2[免费观看]基于 ...

随机推荐

  1. 京东Alpha平台开发笔记系列(二)

    第一篇博文简单讲了一下京东Alpha平台与个人idea技能,本篇将讲解Alpha平台与个人开发需要的一些知识,下面开篇 ——>>> 上图就是京东Alpha技能平台的首页,Skill平 ...

  2. require()  module.export    Object.keys()

    import API from"../../api/api.js";   var data = require('../../utils/data.js').songs;   // ...

  3. npm Error: Cannot find module './auth.js'

    Mac 下升级 npm 到 v6.8.0 翻车. 提示: Error: Cannot find module './auth.js' 根据回显的报错路径,定位到这个文件中: npm/node_modu ...

  4. 携带cookie的跨域访问

    携带cookie的跨域解决方案 有的时候访问后台的请求需要携带cookie以供后台分析,比如jQuery的ajax请求: $.ajax({ url: a_cross_domain_url, xhrFi ...

  5. python学习,excel操作之xlsxwriter常用操作

    from datetime import datetime import xlsxwriter #打开文件 workbook = xlsxwriter.Workbook('Expenses03.xls ...

  6. Beta冲刺 (2/7)

    Part.1 开篇 队名:彳艮彳亍团队 组长博客:戳我进入 作业博客:班级博客本次作业的链接 Part.2 成员汇报 组员1(组长)柯奇豪 过去两天完成了哪些任务 熟悉并编写小程序的自定义控件 展示G ...

  7. Debian 8下手工安装 Eclipse CDT neon.2

    从 http://www.eclipse.org/downloads/packages/eclipse-ide-cc-developers/neon2 下载 eclipse-cpp-neon-2-li ...

  8. 自兴人工智能 python特点了解

    计算机语言从语言执行分类来看,大概可分为编译型语言(如Java.c++)和解释型语言(如python.javascript) 1.编译型语言  java   c++ 编写源代码.java ---> ...

  9. QEMU KVM libvirt 手册(1): 安装

    安装 对虚拟化的支持通常在BIOS中是禁掉的,必须开启才可以. 对于Intel CPU,我们可以通过下面的命令查看是否支持虚拟化. # grep "vmx" /proc/cpuin ...

  10. 背水一战 Windows 10 (97) - 选取器: CachedFileUpdater

    [源码下载] 背水一战 Windows 10 (97) - 选取器: CachedFileUpdater 作者:webabcd 介绍背水一战 Windows 10 之 选取器 CachedFileUp ...