目录
- 常见面试题
- 版本
- 部署
- 命令
- 概述
- 应用场景?
- Redis和Memcached的区别
- 原理
- 开发规范
- 分布式锁
- Redisson实现分布式锁
- 使用Redis实现排行榜功能
- Troubleshooting
- 客户端连接问题
- 备份恢复
- 限流器
- 热点key问题的发现与解决
- 消息队列
- 延时队列
- Redis Sentinal
- Redis Cluster
- FAQ
- 如何保证数据库与redis缓存一致
- 缓存穿透
- 缓存雪崩
- 缓存击穿
- 缓存一致性问题
- 数据结构
- 常用操作
- Lua
- 分布式
- 其他
- 客户端
- Redisson客户端
- 淘汰策略
- redis常见性能问题和解决方案
- redis的缓存失效策略和主键失效机制
- redis事物的了解CAS(check-and-set 操作实现乐观锁 )?
- 删除策略
常见面试题
版本
redis约定次版本号(第一个小数点后的数字)为偶数版本是稳定版,如2.8、3.0, 奇数版本为非稳定版,生产环
境需要使用稳定版;
部署
- 需要安装tcl yum install tcl 、 yum install gcc
- error: jemalloc/jemalloc.h: No such file or directory
说关于分配器allocator, 如果有MALLOC 这个 环境变量, 会有用这个环境变量的 去建立Redis。
而且libc 并不是默认的 分配器, 默认的是 jemalloc, 因为 jemalloc 被证明 有更少的 fragmentation
problems 比libc。
但是如果你又没有jemalloc 而只有 libc 当然 make 出错。 所以加这么一个参数。
解决办法
make MALLOC=libc
make test 测试编译状态
make install {PREFIX=/path}
命令
Redis-server Redis服务器
Redis-cli Redis命令行客户端
Redis-benchmark Redis性能测试工具
Redis-check-aof Aof文件修复工具
Redis-check-dump Rdb文件检查工具
Redis-sentinel Sentinel服务器(2.8以后)
启动
redis-server …/redis.conf
服务器启动后默认使用的是6379的端口 ,通过–port可以自定义端口 ;
Redis-server --port 6380
以守护进程的方式启动,需要修改redis.conf配置文件中daemonize yes
\2. 停止redis
redis-cli SHUTDOWN
考虑到redis有可能正在将内存的数据同步到硬盘中,强行终止redis进程可能会导致数据丢失,正确停止redis的方
式应该是向Redis发送SHUTDOW命令
当redis收到SHUTDOWN命令后,会先断开所有客户端连接,然后根据配置执行持久化,最终完成退出
概述
Redis是一个开源(BSD许可)的内存数据结构存储,用作数据库、缓存和消息代理。Redis提供诸如字符串、哈希、列表、集合、带范围查询的排序集合、位图、超日志、地理空间索引和流等数据结构。Redis具有内置的复制、Lua脚本、LRU逐出、事务和不同级别的磁盘持久性,并通过Redis Sentinel和Redis Cluster的自动分区提供高可用性。
官方提供的数据可以达到100000+的QPS(每秒内的查询次数),这个数据不比Memcached差!
您是想问Redis这么快,为什么还是单线程的吧。Redis确实是单进程单线程的模型,因为Redis完全是基于内存的操作,CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章的采用单线程的方案了(毕竟采用多线程会有很多麻烦)。
可以这么说吧。第一:Redis完全基于内存,绝大部分请求是纯粹的内存操作,非常迅速,数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度是O(1)。第二:数据结构简单,对数据操作也简单。第三:采用单线程,避免了不必要的上下文切换和竞争条件,不存在多线程导致的CPU切换,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有死锁问题导致的性能消耗。第四:使用多路复用IO模型,非阻塞IO。
Redis实现的命令的完整列表,以及每个命令的完整文档。
流水线:学习如何一次发送多个命令,节省往返时间。
Redis发布/订阅:Redis是一个快速稳定的发布/订阅消息系统!过来看。
Redis Lua脚本:Redis Lua脚本特性文档。
调试Lua脚本:redis3.2为Redis脚本引入了一个本地Lua调试器。
内存优化:了解Redis如何使用RAM,并学习一些技巧以减少使用。
过期:Redis允许为每个密钥设置一个不同的生存时间,以便在密钥过期时自动从服务器中删除该密钥。
Redis作为LRU缓存:如何配置和使用Redis作为具有固定内存量和自动收回密钥的缓存。
Redis事务:可以将命令分组在一起,以便它们作为单个事务执行。
客户端缓存:从版本6开始,Redis支持服务器辅助的客户端缓存。本文档介绍如何使用它。
海量数据插入:如何在短时间内将大量预先存在或生成的数据添加到Redis实例中。
分区:如何在多个Redis实例之间分配数据。
分布式锁:用Redis实现分布式锁管理器。
Redis键空间通知:通过Pub/Sub(Redis 2.8或更高版本)获取键空间事件的通知。
使用Redis创建二级索引:使用Redis数据结构创建二级索引、组合索引和遍历图。
Redis模块API
Redis模块简介。开始学习Redis4.0模块编程的好地方。
实现本机数据类型。模块可以实现看起来像内置数据类型的新数据类型(数据结构等)。本文档介绍了执行此操作的API。
模块阻塞操作。这仍然是一个实验性的API,但是它非常强大,可以编写命令来阻止客户机(而不阻止Redis),并可以在其他线程中执行任务。
Redis模块API参考。直接从src/module.c中源代码的顶部注释生成。包含许多关于API用法的低级细节。
Redis数据类型简介:这是学习redisapi和数据模型的一个很好的起点。
Redis streams简介:详细介绍Redis 5新的数据类型Stream。
用PHP和Redis编写一个简单的Twitter克隆
使用Redis自动完成
数据类型简短摘要:Redis支持的不同类型的值的简短摘要,不像本节中列出的第一个教程那样更新和丰富。
常见问题解答:关于Redis的一些常见问题。
管理
rediscli:学习如何掌握Redis命令行界面,在管理、故障排除和试验Redis时,您将经常使用它。
配置:如何配置redis。
复制:为了设置主副本复制,您需要知道什么。
持久性:在配置Redis的持久性时要知道你的选择。
Redis管理:选定的管理主题。
安全性:Redis安全性概述。
Redis访问控制列表:从版本6开始Redis支持acl。可以将用户配置为只能运行选定的命令,并且只能访问特定的密钥模式。
加密:如何加密Redis客户机-服务器通信。
信号处理:Redis如何处理信号。
连接处理:Redis如何处理客户端连接。
高可用性:Redis Sentinel是Redis的官方高可用性解决方案。
延迟监控:Redis集成的延迟监控和报告功能有助于为低延迟工作负载优化Redis实例。
基准测试:看看Redis在不同平台上有多快。
Redis发布:Redis开发周期和版本编号。
嵌入式和物联网
Redis-on-ARM和Raspberry-Pi:从Redis 4.0开始,ARM和Raspberry-Pi是官方支持的平台。本页包含一般信息和基准。
可在此处找到物联网和边缘计算的Redis参考实现。
Redis集群
Redis集群教程:一个温和的Redis集群介绍和安装指南。
Redis集群规范:对Redis集群中使用的行为和算法的更正式的描述。
基于Redis的其他分布式系统
Redis-CRDTs是一个用于Redis的主动-主动地理分布解决方案。
Roshi是基于Redis的时间戳事件的大规模CRDT集实现,在Go中实现。它最初是为SoundCloud流开发的。
SSD和持久内存上的Redis
Redis实验室的Redis on Flash通过SSD和持久性内存扩展了DRAM容量。
规格
Redis设计草案:新提案的设计草案。
Redis协议规范:如果您正在实现一个客户机,或者出于好奇,请学习如何在低级别上与Redis通信。
Redis RDB格式规范和RDB版本历史。
应用场景?
Redis最适合所有数据in-momory的场景,如:1.会话缓存(Session Cache)最常用的一种使用Redis的情景是会话缓存(session cache)。用Redis缓存会话比其他存储(如Memcached)的优势在于:Redis提供持久化。2.全页缓存(FPC)除基本的会话token之外,Redis还提供很简便的FPC平台。回到一致性问题,即使重启了Redis实例,因为有磁盘的持久化,用户也不会看到页面加载速度的下降,这是一个极大改进,类似PHP本地FPC。3.队列Reids在内存存储引擎领域的一大优点是提供 list 和 set 操作,这使得Redis能作为一个很好的消息队列平台来使用。Redis作为队列使用的操作,就类似于本地程序语言(如Python)对 list 的 push/pop 操作。如果你快速的在Google中搜索“Redis queues”,你马上就能找到大量的开源项目,这些项目的目的就是利用Redis创建非常好的后端工具,以满足各种队列需求。例如,Celery有一个后台就是使用Redis作为broker,你可以从这里去查看。4.排行榜/计数器Redis在内存中对数字进行递增或递减的操作实现的非常好。集合(Set)和有序集合(Sorted Set)也使得我们在执行这些操作的时候变的非常简单,Redis只是正好提供了这两种数据结构。所以,我们要从排序集合中获取到排名最靠前的10个用户–我们称之为“user_scores”,我们只需要像下面一样执行即可:当然,这是假定你是根据你用户的分数做递增的排序。如果你想返回用户及用户的分数,你需要这样执行:
ZRANGE user_scores 0 10 WITHSCORES
Agora Games就是一个很好的例子,用Ruby实现的,它的排行榜就是使用Redis来存储数据的,你可以在这里看到。5.发布/订阅最后(但肯定不是最不重要的)是Redis的发布/订阅功能。发布/订阅的使用场景确实非常多。推荐阅读:Redis 的 8 大应用场景。
Redis和Memcached的区别
存储方式上:memcache会把数据全部存在内存之中,断电后会挂掉,数据不能超过内存大小。redis有部分数据存在硬盘上,这样能保证数据的持久性。
2、数据支持类型上:memcache对数据类型的支持简单,只支持简单的key-value,,而redis支持五种数据类型。
3、使用底层模型不同:它们之间底层实现方式以及与客户端之间通信的应用协议不一样。redis直接自己构建了VM机制,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求。
4、value的大小:redis可以达到1GB,而memcache只有1MB。
原理
单线程模型为何效率高
避免了多线程的频繁上下文切换
绝大部分请求是纯粹的内存操作(非常快速)
采用单线程,避免了不必要的上下文切换和竞争条件
基于非阻塞的IO多路复用机制
内部实现采用 epoll,采用了 epoll+自己实现的简单的事件框架。epoll 中的读、写、关闭、连接都转化成了事件,然后利用 epoll 的多路复用特性,绝不在 io 上浪费一点时间。
文件事件处理器
Redis基于Reactor模式开发了网络事件处理器,这个处理器被称为文件事件处理器。它的组成结构为4部分:多个套接字、IO多路复用程序、文件事件分派器、事件处理器。因为文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型。
消息处理流程
文件事件处理器使用I/O多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
尽管多个文件事件可能会并发地出现,但I/O多路复用程序总是会将所有产生事件的套接字都推到一个队列里面,然后通过这个队列,以有序(sequentially)、同步(synchronously)、每次一个套接字的方式向文件事件分派器传送套接字:当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕), I/O多路复用程序才会继续向文件事件分派器传送下一个套接字。
Redis采用了一种非常简单的做法,单线程来处理来自所有客户端的并发请求,Redis把任务封闭在一个线程中从而
避免了线程安全问题;redis为什么是单线程?
官方的解释是,CPU并不是Redis的瓶颈所在,Redis的瓶颈主要在机器的内存和网络的带宽。那么Redis能不能处
理高并发请求呢?当然是可以的,至于怎么实现的,我们来具体了解一下。 【注意并发不等于并行,并发性I/O
流,意味着能够让一个计算单元来处理来自多个客户端的流请求。并行性,意味着服务器能够同时执行几个事情,
具有多个计算单元】
多路复用
Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞
的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导致整个进程无法对其它客户提
供服务,而 I/O 多路复用就是为了解决这个问题而出现的。
了解多路复用之前,先简单了解下几种I/O模型
(1)同步阻塞IO(Blocking IO):即传统的IO模型。
(2)同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为
NONBLOCK。
(3)IO多路复用(IO Multiplexing):即经典的Reactor设计模式,也称为异步阻塞IO,Java中的Selector和
Linux中的epoll都是这种模型。
(4)异步IO(Asynchronous IO):即经典的Proactor设计模式,也称为异步非阻塞IO。
同步和异步,指的是用户线程和内核的交互方式
阻塞和非阻塞,指用户线程调用内核IO操作的方式是阻塞还是非阻塞
就像在Java中使用多线程做异步处理的概念,通过多线程去执行一个流程,主线程可以不用等待。而阻塞和非阻塞
我们可以理解为假如在同步流程或者异步流程中做IO操作,如果缓冲区数据还没准备好,IO的这个过程会阻塞
在Redis中使用Lua脚本
我们在使用redis的时候,会面临一些问题,比如
原子性问题
前面我们讲过,redis虽然是单一线程的,当时仍然会存在线程安全问题,当然,这个线程安全问题不是来源安于
Redis服务器内部。而是Redis作为数据服务器,是提供给多个客户端使用的。多个客户端的操作就相当于同一个进
程下的多个线程,如果多个客户端之间没有做好数据的同步策略,就会产生数据不一致的问题。举个简单的例子
多个客户端的命令之间没有做请求同步,导致实际执行顺序可能会不一致,最终的结果也就无法满足原子性了。
效率问题
redis本身的吞吐量是非常高的,因为它首先是基于内存的数据库。在实际使用过程中,有一个非常重要的因素影
响redis的吞吐量,那就是网络。我们在使用redis实现某些特定功能的时候,很可能需要多个命令或者多个数据类
型的交互才能完成,那么这种多次网络请求对性能影响比较大。当然redis也做了一些优化,比如提供了pipeline管
道操作,但是它有一定的局限性,就是执行的多个命令和响应之间是不存在相互依赖关系的。所以我们需要一种机制能够编写一些具有业务逻辑的命令,减少网络请求
I/O 多路复用程序的实现
Redis的I/O多路复用程序的所有功能是通过包装select、epoll、evport和kqueue这些I/O多路复用函数库来实现的,每个I/O多路复用函数库在Redis源码中都对应一个单独的文件,比如ae_select.c、ae_epoll.c、ae_kqueue.c等。
因为Redis为每个I/O多路复用函数库都实现了相同的API,所以I/O多路复用程序的底层实现是可以互换的,如下图所示
Redis在I/O多路复用程序的实现源码中用#include宏定义了相应的规则,程序会在编译时自动选择系统中性能最好的I/O多路复用函数库来作为Redis的I/O多路复用程序的底层实现:
文件事件的类型
I/O 多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE事件,这两类事件和套接字操作之间的对应关系如下:
当套接字变得可读时(客户端对套接字执行write操作,或者执行close操作),或者有新的可应答(acceptable)套接字出现时(客户端对服务器的监听套接字执行connect操作),套接字产生AE_READABLE 事件。
当套接字变得可写时(客户端对套接字执行read操作),套接字产生AE_WRITABLE事件。I/O多路复用程序允许服务器同时监听套接字的AE_READABLE事件和AE_WRITABLE事件,如果一个套接字同时产生了这两种事件,那么文件事件分派器会优先处理AE_READABLE事件,等到AE_READABLE事件处理完之后,才处理AE_WRITABLE 事件。这也就是说,如果一个套接字又可读又可写的话,那么服务器将先读套接字,后写套接字。
文件事件的处理器
Redis为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通讯需求,常用的处理器如下:
为了对连接服务器的各个客户端进行应答, 服务器要为监听套接字关联连接应答处理器。
为了接收客户端传来的命令请求, 服务器要为客户端套接字关联命令请求处理器。
为了向客户端返回命令的执行结果, 服务器要为客户端套接字关联命令回复处理器。
连接应答处理器
networking.c中acceptTcpHandler函数是Redis的连接应答处理器,这个处理器用于对连接服务器监听套接字的客户端进行应答,具体实现为sys/socket.h/accept函数的包装。
当Redis服务器进行初始化的时候,程序会将这个连接应答处理器和服务器监听套接字的AE_READABLE事件关联起来,当有客户端用sys/socket.h/connect函数连接服务器监听套接字的时候, 套接字就会产生AE_READABLE 事件, 引发连接应答处理器执行, 并执行相应的套接字应答操作,如图所示。
命令请求处理器
networking.c中readQueryFromClient函数是Redis的命令请求处理器,这个处理器负责从套接字中读入客户端发送的命令请求内容, 具体实现为unistd.h/read函数的包装。
当一个客户端通过连接应答处理器成功连接到服务器之后, 服务器会将客户端套接字的AE_READABLE事件和命令请求处理器关联起来,当客户端向服务器发送命令请求的时候,套接字就会产生 AE_READABLE事件,引发命令请求处理器执行,并执行相应的套接字读入操作,如图所示。
在客户端连接服务器的整个过程中,服务器都会一直为客户端套接字的AE_READABLE事件关联命令请求处理器。
命令回复处理器
networking.c中sendReplyToClient函数是Redis的命令回复处理器,这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端,具体实现为unistd.h/write函数的包装。
当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的AE_WRITABLE事件和命令回复处理器关联起来,当客户端准备好接收服务器传回的命令回复时,就会产生AE_WRITABLE事件,引发命令回复处理器执行,并执行相应的套接字写入操作, 如图所示。
当命令回复发送完毕之后, 服务器就会解除命令回复处理器与客户端套接字的 AE_WRITABLE 事件之间的关联。
一次完整的客户端与服务器连接事件示例
假设Redis服务器正在运作,那么这个服务器的监听套接字的AE_READABLE事件应该正处于监听状态之下,而该事件所对应的处理器为连接应答处理器。
如果这时有一个Redis客户端向Redis服务器发起连接,那么监听套接字将产生AE_READABLE事件, 触发连接应答处理器执行:处理器会对客户端的连接请求进行应答, 然后创建客户端套接字,以及客户端状态,并将客户端套接字的 AE_READABLE 事件与命令请求处理器进行关联,使得客户端可以向主服务器发送命令请求。
之后,客户端向Redis服务器发送一个命令请求,那么客户端套接字将产生 AE_READABLE事件,引发命令请求处理器执行,处理器读取客户端的命令内容, 然后传给相关程序去执行。
执行命令将产生相应的命令回复,为了将这些命令回复传送回客户端,服务器会将客户端套接字的AE_WRITABLE事件与命令回复处理器进行关联:当客户端尝试读取命令回复的时候,客户端套接字将产生AE_WRITABLE事件, 触发命令回复处理器执行, 当命令回复处理器将命令回复全部写入到套接字之后, 服务器就会解除客户端套接字的AE_WRITABLE事件与命令回复处理器之间的关联。
epoll
epoll是一种I/O事件通知机制,是linux 内核实现IO多路复用的一个实现。
IO多路复用是指,在一个操作里同时监听多个输入输出源,在其中一个或多个输入输出源可用的时候返回,然后对其的进行读写操作。
输入输出(input/output)的对象可以是文件(file), 网络(socket),进程之间的管道(pipe)。在linux系统中,都用文件描述符(fd)来表示。
可读事件,当文件描述符关联的内核读缓冲区可读,则触发可读事件。(可读:内核缓冲区非空,有数据可以读取)
可写事件,当文件描述符关联的内核写缓冲区可写,则触发可写事件。(可写:内核缓冲区不满,有空闲空间可以写入)
通知机制,就是当事件发生的时候,则主动通知。通知机制的反面,就是轮询机制。
epoll的通俗解释是一种当文件描述符的内核缓冲区非空的时候,发出可读信号进行通知,当写缓冲区不满的时候,发出可写信号通知的机制
epoll的核心是3个API,核心数据结构是:1个红黑树和1个链表
- int epoll_create(int size)
功能:
内核会产生一个epoll 实例数据结构并返回一个文件描述符,这个特殊的描述符就是epoll实例的句柄,后面的两个接口都以它为中心(即epfd形参)。
size参数表示所要监视文件描述符的最大值,不过在后来的Linux版本中已经被弃用(同时,size不要传0,会报invalid argument错误)
- int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
功能:
将被监听的描述符添加到红黑树或从红黑树中删除或者对监听事件进行修改
typedef union epoll_data {
void ptr; / 指向用户自定义数据 /
int fd; / 注册的文件描述符 /
uint32_t u32; / 32-bit integer /
uint64_t u64; / 64-bit integer */
} epoll_data_t;
struct epoll_event {
uint32_t events; /* 描述epoll事件 /
epoll_data_t data; / 见上面的结构体 */
};
对于需要监视的文件描述符集合,epoll_ctl对红黑树进行管理,红黑树中每个成员由描述符值和所要监控的文件描述符指向的文件表项的引用等组成。
op参数说明操作类型:
EPOLL_CTL_ADD:向interest list添加一个需要监视的描述符
EPOLL_CTL_DEL:从interest list中删除一个描述符
EPOLL_CTL_MOD:修改interest list中一个描述符
struct epoll_event结构描述一个文件描述符的epoll行为。在使用epoll_wait函数返回处于ready状态的描述符列表时,
data域是唯一能给出描述符信息的字段,所以在调用epoll_ctl加入一个需要监测的描述符时,一定要在此域写入描述符相关信息
events域是bit mask,描述一组epoll事件,在epoll_ctl调用中解释为:描述符所期望的epoll事件,可多选。
常用的epoll事件描述如下:
EPOLLIN:描述符处于可读状态
EPOLLOUT:描述符处于可写状态
EPOLLET:将epoll event通知模式设置成edge triggered
EPOLLONESHOT:第一次进行通知,之后不再监测
EPOLLHUP:本端描述符产生一个挂断事件,默认监测事件
EPOLLRDHUP:对端描述符产生一个挂断事件
EPOLLPRI:由带外数据触发
EPOLLERR:描述符产生错误时触发,默认检测事件
- int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
功能:
阻塞等待注册的事件发生,返回事件的数目,并将触发的事件写入events数组中。
events: 用来记录被触发的events,其大小应该和maxevents一致
maxevents: 返回的events的最大个数
处于ready状态的那些文件描述符会被复制进ready list中,epoll_wait用于向用户进程返回ready list。events和maxevents两个参数描述一个由用户分配的struct epoll event数组,调用返回时,内核将ready list复制到这个数组中,并将实际复制的个数作为返回值。注意,如果ready list比maxevents长,则只能复制前maxevents个成员;反之,则能够完全复制ready list。
另外,struct epoll event结构中的events域在这里的解释是:在被监测的文件描述符上实际发生的事件。
参数timeout描述在函数调用中阻塞时间上限,单位是ms:
timeout = -1表示调用将一直阻塞,直到有文件描述符进入ready状态或者捕获到信号才返回;
timeout = 0用于非阻塞检测是否有描述符处于ready状态,不管结果怎么样,调用都立即返回;
timeout > 0表示调用将最多持续timeout时间,如果期间有检测对象变为ready状态或者捕获到信号则返回,否则直到超时。
epoll的两种触发方式
epoll监控多个文件描述符的I/O事件。epoll支持边缘触发(edge trigger,ET)或水平触发(level trigger,LT),通过epoll_wait等待I/O事件,如果当前没有可用的事件则阻塞调用线程。
select和poll只支持LT工作模式,epoll的默认的工作模式是LT模式。
1.水平触发的时机
对于读操作,只要缓冲内容不为空,LT模式返回读就绪。
对于写操作,只要缓冲区还不满,LT模式会返回写就绪。
当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你。如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。
2.边缘触发的时机
对于读操作
当缓冲区由不可读变为可读的时候,即缓冲区由空变为不空的时候。
当有新数据到达时,即缓冲区中的待读数据变多的时候。
当缓冲区有数据可读,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLIN事件时。
对于写操作
当缓冲区由不可写变为可写时。
当有旧数据被发送走,即缓冲区中的内容变少的时候。
当缓冲区有空间可写,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLOUT事件时。
当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
在ET模式下, 缓冲区从不可读变成可读,会唤醒应用进程,缓冲区数据变少的情况,则不会再唤醒应用进程。
举例1:
读缓冲区刚开始是空的
读缓冲区写入2KB数据
水平触发和边缘触发模式此时都会发出可读信号
收到信号通知后,读取了1KB的数据,读缓冲区还剩余1KB数据
水平触发会再次进行通知,而边缘触发不会再进行通知
举例2:(以脉冲的高低电平为例)
水平触发:0为无数据,1为有数据。缓冲区有数据则一直为1,则一直触发。
边缘触发发:0为无数据,1为有数据,只要在0变到1的上升沿才触发。
JDK并没有实现边缘触发,Netty重新实现了epoll机制,采用边缘触发方式;另外像Nginx也采用边缘触发
JDK在Linux已经默认使用epoll方式,但是JDK的epoll采用的是水平触发,而Netty重新实现了epoll机制,采用边缘触发方式,netty epoll transport 暴露了更多的nio没有的配置参数,如 TCP_CORK, SO_REUSEADDR等等;另外像Nginx也采用边缘触发。
epoll与select、poll的对比
-
用户态将文件描述符传入内核的方式
select:创建3个文件描述符集并拷贝到内核中,分别监听读、写、异常动作。这里受到单个进程可以打开的fd数量限制,默认是1024。
poll:将传入的struct pollfd结构体数组拷贝到内核中进行监听。
epoll:执行epoll_create会在内核的高速cache区中建立一颗红黑树以及就绪链表(该链表存储已经就绪的文件描述符)。接着用户执行的epoll_ctl函数添加文件描述符会在红黑树上增加相应的结点。 -
内核态检测文件描述符读写状态的方式
select:采用轮询方式,遍历所有fd,最后返回一个描述符读写操作是否就绪的mask掩码,根据这个掩码给fd_set赋值。
poll:同样采用轮询方式,查询每个fd的状态,如果就绪则在等待队列中加入一项并继续遍历。
epoll:采用回调机制。在执行epoll_ctl的add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中。 -
找到就绪的文件描述符并传递给用户态的方式
select:将之前传入的fd_set拷贝传出到用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
poll:将之前传入的fd数组拷贝传出用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
epoll:epoll_wait只用观察就绪链表中有无数据即可,最后将链表的数据返回给数组并返回就绪的数量。内核将就绪的文件描述符放在传入的数组中,所以只用遍历依次处理即可。这里返回的文件描述符是通过mmap让内核和用户空间共享同一块内存实现传递的,减少了不必要的拷贝。 -
重复监听的处理方式
select:将新的监听文件描述符集合拷贝传入内核中,继续以上步骤。
poll:将新的struct pollfd结构体数组拷贝传入内核中,继续以上步骤。
epoll:无需重新构建红黑树,直接沿用已存在的即可。
epoll更高效的原因
select和poll的动作基本一致,只是poll采用链表来进行文件描述符的存储,而select采用fd标注位来存放,所以select会受到最大连接数的限制,而poll不会。
select、poll、epoll虽然都会返回就绪的文件描述符数量。但是select和poll并不会明确指出是哪些文件描述符就绪,而epoll会。造成的区别就是,系统调用返回后,调用select和poll的程序需要遍历监听的整个文件描述符找到是谁处于就绪,而epoll则直接处理即可。
select、poll都需要将有关文件描述符的数据结构拷贝进内核,最后再拷贝出来。而epoll创建的有关文件描述符的数据结构本身就存于内核态中,系统调用返回时利用mmap()文件映射内存加速与内核空间的消息传递:即epoll使用mmap减少复制开销。
select、poll采用轮询的方式来检查文件描述符是否处于就绪态,而epoll采用回调机制。造成的结果就是,随着fd的增加,select和poll的效率会线性降低,而epoll不会受到太大影响,除非活跃的socket很多。
epoll的边缘触发模式效率高,系统不会充斥大量不关心的就绪文件描述符
虽然epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
开发规范
以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如业务名:表名:id
ugc:video:1
保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视,例如:
user:{uid}:friends:messages:{mid}简化为u:{uid}🇫🇷m:{mid}。
string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000。
非字符串的bigkey,不要使用del删除,使用hscan、sscan、zscan方式渐进式删除,同时要注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发del操作,造成阻塞,而且该操作不会不出现在慢查询中(latency可查)),查找方法和删除方法
例如:实体类型(要合理控制和使用数据结构内存编码优化配置,例如ziplist,但也要注意节省内存和性能之间的平衡)
hmset user:1 name tom age 19 favor football
建议使用expire设置过期时间(条件允许可以打散过期时间,防止集中过期),不过期的数据重点关注idletime。
例如hgetall、lrange、smembers、zrange、sinter等并非不能使用,但是需要明确N的值。有遍历的需求可以使用hscan、sscan、zscan代替。
建议将热数据 (如 QPS超过 5k) 的数据加载到 Redis 中。低频数据可存储在 Mysql、 ElasticSearch中
不要将不相关的数据业务都放到一个 Redis中
执行KEYS命令会查找所有符合条件的key,非常的占用CPU资源,容易造成性能问题,建议客户改成scan命令或者禁用KEYS命令。
为了减少大Key和热Key过大,有什么使用建议?
string类型控制在10KB以内,hash、list、set、zset元素尽量不超过5000。
Redis 是单线程服务,消息过大会阻塞并拖慢其他操作。保持消息内容在 1KB 以下是个好的习惯。严禁超过 50KB 的单条记录。消息过大还会引起网络带宽的高占用,持久化到磁盘时的 IO 问题。
Redis事务功能较弱,不建议过多使用。
短连接性能差,推荐使用带有连接池的客户端。
如果只是用于数据缓存,容忍数据丢失,建议关闭持久化。
key的命名前缀为业务缩写,禁止包含特殊字符(比如空格、换行、单双引号以及其他转义字符)。
Key名称
使用统一的命名规范。
一般使用业务名(或数据库名)为前缀,用冒号分隔,例如,业务名:表名:id。
一般,一个 key 需要带以下维度:业务、key 用途、变量等,各个维度使用 : 进行分隔,以下是几个 key 的实例:
user:sex 用户 10002232 的性别
msg:achi 201712 的用户发言数量排行榜
控制key名称的长度。
在保证语义清晰的情况下,尽量减少Key的长度。有些常用单词可使用缩写,例如,user缩写为u,messages缩写为msg。
名称中不要包含特殊字符。
要避免大Key。
大Key会带来网卡流量风暴和慢查询,一般string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000。
选择合适的数据类型。
比如存储用户的信息,可用使用多个key,使用set u:1:name “X”、set u:1:age 20这样存储,也可以使用hash数据结构,存储成1个key,设置用户属性时使用hmset一次设置多个,同时这样存储也能节省内存。
能够根据某类 key 进行数据清理
能够根据某类 key 进行数据更新
能够方面了解到某类 key 的归属方和应用场景
为统一化、平台化做准备,减少技术变更
设置合理的过期时间。
最好是过期时间打散,不要集中在某个时间点过期
Redis命令
时间复杂度为O(N)的命令,需要特别注意N的值。
例如:hgetall、lrange、smembers、zrange、sinter这些命令都是做全集操作,如果元素很多,是很耗性能的。可使用hscan、sscan、zscan这些分批扫描的命令替代。命令禁用,使用前,请参考Redis命令兼容性和WebCli命令兼容性。
慎重使用select。
Redis多数据库支持较弱,多业务用多数据库实际还是单线程处理,会有干扰。最好是拆分使用多个Redis。
如果有批量操作,可使用mget、mset或pipeline,提高效率,但要注意控制一次批量操作的元素个数。
mget、mset和pipeline的区别如下:
a. mget和mset是原子操作,pipeline是非原子操作。
b. pipeline可以打包不同的命令,mget和mset做不到。
c. 使用pipeline,需要客户端和服务端同时支持。
连接数限制
连接的频繁创建和销毁,会浪费大量的系统资源,极限情况会造成宿主机当机。请确保使用了正确的 Redis 客户端连接池配置。
缓存 Key 设置失效时间
请根据业务性质进行设置。注意,失效时间的单位有的是秒,有的是毫秒
redis 内存数据集大小上升到一定大小的时候,就会施行数据淘汰策略。
缓存应该仅作缓存用,去掉后业务逻辑不应发生改变,万不可切入到业务里
集群越大,在状态同步和持久化方面的性能越差。 优先使用客户端 hash 进行集群拆分。如:根据用户 id 分 10 个集群,用户尾号为 0 的落在第一个集群
Keys 命令效率极低,属于 O(N)操作,会阻塞其他正常命令,在 cluster 上,会是灾难性的操作。严禁使用,DBA 应该 rename 此命令,从根源禁用。
flush 命令会清空所有数据,属于高危操作。严禁使用,DBA 应该 rename 此命令,从根源禁用,仅 DBA 可操作
Redis 当作消息队列使用,会有容量、网络、效率、功能方面的多种问题。如需要消息队列,可使用高吞吐的 Kafka 或者高可靠的 RocketMQ。
redis 那么快,慢查询除了网络延迟,就属于这些批量操作函数。大多数线上问题都是由于这些函数引起。
1、[zset] 严禁对 zset 的不设范围操作
2、ZRANGE、 ZRANGEBYSCORE等多个操作 ZSET 的函数,严禁使用 ZRANGE myzset 0 -1 等这种不设置范围的操作。请指定范围,如 ZRANGE myzset 0 100。如不确定长度,可使用 ZCARD 判断长度
3、[hash] 严禁对大数据量 Key 使用 HGETALL
4、HGETALL会取出相关 HASH 的所有数据,如果数据条数过大,同样会引起阻塞,请确保业务可控。如不确定长度,可使用 HLEN 先判断长度
5、[key] Redis Cluster 集群的 mget 操作
Redis Cluster 的 MGET 操作,会到各分片取数据聚合,相比传统的 M/S架构,性能会下降很多,请提前压测和评估
6、[其他] 严禁使用 sunion, sinter, sdiff等一些聚合操作
select函数用来切换 database,对于使用方来说,这是很容易发生问题的地方,cluster 模式也不支持多个 database,且没有任何收益,禁用。
redis 本身已经很快了,如无大的必要,建议捕获异常进行回滚,不要使用事务函数
lua 脚本虽然能做很多看起来很 cool 的事情,但它就像是 SQL 的存储过程,会引入性能和一些难以维护的问题,禁用
monitor函数可以快速看到当前 redis 正在执行的数据流,但是当心,高峰期长时间阻塞在 monitor 命令上,会严重影响 redis 的性能。此命令不禁止使用,但使用一定要特别特别注意。
1、能够根据某类 key 进行数据清理
2、能够根据某类 key 进行数据更新
3、能够方面了解到某类 key 的归属方和应用场景
4、为统一化、平台化做准备,减少技术变更
lua脚本的执行超时时间为5秒钟,建议不要在lua脚本中使用比较耗时的代码,比如长时间的sleep、大的循环等语句。
调用lua脚本时,建议不要使用随机函数去指定key,否则在主备节点上执行结果不一致,从而导致主备节点数据不一致。
集群实例使用lua有如下限制:
a.使用EVAL和EVALSHA命令时,命令参数中必须带有至少1个key,否则客户端会提示“ERR eval/evalsha numkeys must be bigger than zero in redis cluster mode”的错误。
b.使用EVAL和EVALSHA命令时,DCS Redis集群实例使用第一个key来计算slot,用户代码需要保证操作的key是在同一个slot。
建议使用连接池+长连接,可以有效控制连接,同时提高效率。
合理设置maxmemory-policy(最大内存淘汰策略)。
noeviction:为默认策略,表示不会剔除任何数据,拒绝所有写入操作并返回客户端错误信息"(error) OOM command not allowed when used memory",此时Redis只响应读操作。
volatile-lru:即超过最大内存后,在过期键中使用lru算法进行key的剔除,保证不过期数据不被删除,但是可能会出现OOM问题。
allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止。
allkeys-random:随机删除所有键,直到腾出足够空间为止。
volatile-random:随机删除过期键,直到腾出足够空间为止。
volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略。
删除大Key时,不要直接使用del命令。
如果是Hash类型的大Key,推荐使用hscan + hdel
如果是List类型的大Key,推荐使用ltrim
如果是Set类型的大Key,推荐使用 sscan + srem
如果是SortedSet类型的大Key,推荐使用zscan + zrem
使用Pipeline时,建议不要一次太多命令,集群最大1024。
分布式锁
https://github.com/mrniko/redisson
关于锁,其实我们或多或少都有接触过一些,比如synchronized、 Lock这些,这类锁的目的很简单,在多线程环
境下,对共享资源的访问造成的线程安全问题,通过锁的机制来实现资源访问互斥。那么什么是分布式锁呢?或者
为什么我们需要通过Redis来构建分布式锁,其实最根本原因就是Score(范围),因为在分布式架构中,所有的应
用都是进程隔离的,在多进程访问共享资源的时候我们需要满足互斥性,就需要设定一个所有进程都能看得到的范
围,而这个范围就是Redis本身。所以我们才需要把锁构建到Redis中。
Redis里面提供了一些比较具有能够实现锁特性的命令,比如SETEX(在键不存在的情况下为键设置值),那么我们可
以基于这个命令来去实现一些简单的锁的操作
在许多环境中,不同的进程必须以互斥的方式使用共享资源进行操作时,分布式锁是非常有用的原语。
有许多库和博客文章描述了如何使用Redis实现DLM(分布式锁管理器),但是每个库都使用不同的方法,与使用稍微复杂一些的方法相比,许多方法都使用了具有较低保证的简单方法设计。
该页面试图提供一种更规范的算法来实现Redis的分布式锁。我们提出了一种称为Redlock的算法,该算法实现了DLM,我们认为它比普通的单实例方法更安全。我们希望社区能够对其进行分析,提供反馈,并将其用作实现或更复杂或替代设计的起点。
实作
在描述算法之前,这里有一些指向已经可用的实现的链接,可以用作参考。
Redlock-rb(Ruby实现)。还有Redlock-rb的一个分叉,它添加了一个gem,以便于分发,甚至更多。
Redlock-py(Python实现)。
陶器(Python实现)。
Aioredlock(Asyncio Python实现)。
Redlock-php(PHP实现)。
PHPRedisMutex(更多的PHP实现)
cheprasov / php-redis-lock(用于锁定的PHP库)
rtckit / react-redlock(异步PHP实现)
Redsync(执行Go)。
Redisson(Java实现)。
Redis :: DistLock(Perl实现)。
Redlock-cpp(C ++实现)。
Redlock-cs(C#/。NET实现)。
RedLock.net(C#/。NET实现)。包括异步和锁扩展支持。
ScarletLock(带有可配置数据存储的C#.NET实现)
Redlock4Net(C#.NET实现)
node-redlock(NodeJS实现)。包括对锁扩展的支持。
安全和活泼的保证
我们将仅使用三个属性来对设计进行建模,从我们的角度来看,这三个属性是有效使用分布式锁所需的最低保证。
安全特性:互斥。在任何给定时刻,只有一个客户端可以持有锁。
活力属性A:无死锁。最终,即使锁定资源的客户端崩溃或分区,也始终可以获得锁定。
活动性B:容错能力。只要大多数Redis节点都处于运行状态,客户端就可以获取和释放锁。
为什么基于故障转移的实现还不够
为了了解我们要改进的内容,让我们使用大多数基于Redis的分布式锁库分析当前的事务状态。
使用Redis锁定资源的最简单方法是在实例中创建密钥。使用Redis到期功能,通常在有限的生存时间内创建密钥,以便最终将其释放(我们列表中的属性2)。当客户端需要释放资源时,它将删除密钥。
从表面上看,这很好,但是存在一个问题:这是我们架构中的单点故障。如果Redis主服务器宕机了怎么办?好吧,让我们添加一个奴隶!如果主服务器不可用,请使用它。不幸的是,这是不可行的。这样,我们就无法实现互斥的安全属性,因为Redis复制是异步的。
此模型有一个明显的竞争条件:
客户端A获取主服务器中的锁。
在将密钥写入传输到从机之前,主机崩溃。
奴隶晋升为主人。
客户端B获取对相同资源A的锁定,而该资源A已经为其持有了锁定。安全违规!
有时,在特殊情况下(例如在故障期间),多个客户端可以同时持有锁是完全可以的。在这种情况下,您可以使用基于复制的解决方案。否则,我们建议实施本文档中描述的解决方案。
单个实例正确实施
在尝试克服上述单实例设置的限制之前,让我们先检查一下如何在这种简单情况下正确进行设置,因为这实际上是在不时出现竞争条件的应用程序中可行的解决方案,并且因为单个实例是我们将用于此处描述的分布式算法的基础。
要获取锁,必须遵循以下方法:
SET resource_name my_random_value NX PX 30000
该命令仅在密钥不存在时才设置密钥(NX选项),并且到期时间为30000毫秒(PX选项)。密钥设置为“我的随机值”。该值在所有客户端和所有锁定请求中必须是唯一的。
基本上,使用随机值是为了以安全的方式释放锁,并且脚本会告诉Redis:仅当密钥存在且存储在密钥上的值恰好是我期望的值时,才删除该密钥。这是通过以下Lua脚本完成的:
if redis.call(“get”,KEYS[1]) == ARGV[1] then
return redis.call(“del”,KEYS[1])
else
return 0
end
为了避免删除另一个客户端创建的锁,这一点很重要。例如,一个客户端可能获取了该锁,在某些操作中被阻塞的时间超过了该锁的有效时间(密钥将过期的时间),然后又删除了某个其他客户端已经获取的锁。仅使用DEL是不安全的,因为一个客户端可能会删除另一个客户端的锁。使用上述脚本时,每个锁都由一个随机字符串“签名”,因此仅当该锁仍是客户端尝试将其删除时设置的锁时,该锁才会被删除。
这个随机字符串应该是什么?我假设它是来自/ dev / urandom的20个字节,但是您可以找到更便宜的方法来使其足够独特以完成您的任务。例如,一个安全的选择是使用/ dev / urandom为RC4设置种子,并从中生成一个伪随机流。一个更简单的解决方案是结合使用unix时间和微秒级分辨率,并将其与客户端ID串联在一起,它不那么安全,但在大多数环境中可能可以完成任务。
我们将其用作生存的关键时间,称为“锁定有效时间”。它既是自动释放时间,又是客户端执行另一操作之前客户端可以再次获取锁而技术上不违反互斥保证的时间,该时间仅限于给定的时间范围从获得锁的那一刻起的时间。
因此,现在我们有了获取和释放锁的好方法。该系统基于由一个始终可用的单个实例组成的非分布式系统进行推理,因此是安全的。让我们将概念扩展到没有此类保证的分布式系统。
Redlock算法
在该算法的分布式版本中,我们假设我们有N个Redis母版。这些节点是完全独立的,因此我们不使用复制或任何其他隐式协调系统。我们已经描述了如何在单个实例中安全地获取和释放锁。我们认为该算法将使用此方法在单个实例中获取和释放锁,这是理所当然的。在我们的示例中,我们将N = 5设置为一个合理的值,因此我们需要在不同的计算机或虚拟机上运行5个Redis主服务器,以确保它们将以大多数独立的方式发生故障。
为了获取锁,客户端执行以下操作:
它以毫秒为单位获取当前时间。
它尝试在所有N个实例中依次使用所有实例中相同的键名和随机值来获取锁定。在第2步中,在每个实例中设置锁时,客户端使用的超时时间小于总锁自动释放时间,以便获取该超时时间。例如,如果自动释放时间为10秒,则超时时间可能在5到50毫秒之间。这样可以防止客户端长时间与处于故障状态的Redis节点通信时保持阻塞:如果一个实例不可用,我们应该尝试与下一个实例尽快通信。
客户端通过从当前时间中减去在步骤1中获得的时间戳,来计算获取锁所花费的时间。 ,并且获取锁所花费的总时间小于锁有效时间,则认为已获取锁。
如果获取了锁,则将其有效时间视为初始有效时间减去经过的时间,如步骤3中所计算。
如果客户端由于某种原因(无法锁定N / 2 + 1实例或有效时间为负数)而未能获得该锁,它将尝试解锁所有实例(即使它认为不是该实例)能够锁定)。
算法是异步的吗?
该算法基于这样的假设:尽管各进程之间没有同步时钟,但每个进程中的本地时间仍以近似相同的速率流动,并且与锁的自动释放时间相比,误差较小。这个假设与现实世界的计算机非常相似:每台计算机都有一个本地时钟,我们通常可以依靠不同的计算机来产生很小的时钟漂移。
在这一点上,我们需要更好地指定我们的互斥规则:只有在拥有锁的客户端将在锁有效时间内(如步骤3中获得的)减去一定时间(仅几毫秒)的情况下终止工作,才能保证这一点。以补偿进程之间的时钟漂移)。
有关需要限制时钟漂移的类似系统的更多信息,本文提供了有趣的参考:租约:一种用于分布式文件缓存一致性的有效容错机制。
重试失败
当客户端无法获取锁时,它应在随机延迟后重试,以尝试使试图同时获取同一资源的多个客户端不同步(这可能会导致大脑分裂的情况,其中没人获胜)。同样,客户端在大多数Redis实例中尝试获取锁定的速度越快,出现裂脑情况(以及需要重试)的窗口就越小,因此理想情况下,客户端应尝试将SET命令发送到N个实例同时使用多路复用。
值得强调的是,对于未能获得大多数锁的客户端,尽快释放(部分)获得的锁有多么重要,这样就不必等待密钥期满才能再次获得锁(但是,如果发生网络分区,并且客户端不再能够与Redis实例进行通信,则在等待密钥到期时需要支付可用性损失)。
释放锁
释放锁很简单,只需在所有实例中释放锁,无论客户端是否认为它能够成功锁定给定的实例。
安全论点
该算法安全吗?我们可以尝试了解在不同情况下会发生什么。
首先,让我们假设客户端能够在大多数实例中获取锁。所有实例都将包含一个具有相同生存时间的密钥。但是,密钥是在不同的时间设置的,因此密钥也会在不同的时间失效。但是,如果第一个密钥在时间T1(在与第一台服务器联系之前进行采样的时间)设置为最差,而最后一个密钥在时间T2(从最后一台服务器获得答复的时间)设置为最坏的话,集合中第一个过期的密钥至少存在一次MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT。所有其他键都将在以后失效,因此我们确保至少在这次同时设置这些键。
在设置大多数键的过程中,另一个客户端将无法获取锁,因为如果已经存在N / 2 + 1个键,则N / 2 + 1 SET NX操作将无法成功。因此,如果获取了锁,则不可能同时重新获取它(违反互斥属性)。
但是,我们还要确保尝试同时获取锁的多个客户端不能同时成功。
如果客户端使用接近或大于锁定最大有效时间(我们基本上用于SET的TTL)的时间锁定了大多数实例,则它将认为锁定无效并将对实例进行解锁,因此我们只需要考虑客户端能够在少于有效时间的时间内锁定大多数实例的情况。在这种情况下,对于上面已经说明的参数,MIN_VALIDITY没有客户端应该能够重新获取该锁。因此,只有当大多数锁定时间大于TTL时间时,多个客户端才可以同时锁定N / 2 + 1个实例(“时间”是步骤2的结尾),从而使锁定无效。
您是否能够提供正式的安全证明,指向相似的现有算法或发现错误?这将不胜感激。
活力论据
系统活动性基于三个主要功能:
自动释放锁定(因为密钥过期):最终可以再次使用锁定键。
通常情况下,客户通常会在未获得锁或获得锁且工作终止时合作删除锁,这使得我们不必等待密钥过期即可重新获得锁。锁。
当客户端需要重试锁定时,它等待的时间要比获取大多数锁定所需的时间长得多,以便概率地使资源争用期间的脑裂情况变得不可能。
但是,我们在网络分区上支付的可用性损失等于TTL时间,因此,如果存在连续的分区,我们可以无限期地支付此损失。每当客户端获取锁并在能够删除该锁之前进行分区时,都会发生这种情况。
基本上,如果有无限连续的网络分区,则系统可能会在无限长的时间内不可用。
性能,崩溃恢复和fsync
使用Redis作为锁定服务器的许多用户在获取和释放锁的延迟以及每秒可能执行的获取/释放操作的数量方面都需要高性能。为了满足此要求,与N个Redis服务器进行通信以减少延迟的策略肯定是多路复用(或穷人的多路复用,即将套接字置于非阻塞模式,发送所有命令,并读取所有命令)之后,假设客户端和每个实例之间的RTT相似)。
但是,如果我们要针对崩溃恢复系统模型,则还需要考虑持久性。
基本上在这里看到问题,让我们假设我们完全没有持久性地配置Redis。客户端在5个实例中的3个实例中获取了锁。客户端能够获取锁的一个实例被重新启动,此时,我们可以为同一资源再次锁定3个实例,而另一个客户端可以再次锁定它,这违反了锁的排他性的安全性。
如果启用AOF持久性,则情况将会大大改善。例如,我们可以通过发送SHUTDOWN并重新启动它来升级服务器。因为Redis过期是从语义上实现的,所以实际上在服务器关闭时时间仍然流逝,所以我们的所有要求都很好。但是,只要关闭干净,一切都很好。停电呢?如果默认情况下将Redis配置为每秒在磁盘上进行fsync,则重启后可能会丢失我们的密钥。从理论上讲,如果我们想在遇到任何类型的实例重新启动时确保锁的安全性,则需要在持久性设置中始终启用fsync = always。反过来,这将完全破坏性能,使其达到传统上以安全方式实现分布式锁的CP系统的水平。
然而,事情总比乍看之下要好。基本上,只要实例在崩溃后重新启动时就保持算法安全性,它不再参与任何当前活动的锁,因此实例重新启动时的一组当前活动的锁全部是通过锁定实例而不是其他实例来获得的。正在重新加入系统。
为了保证这一点,我们只需要使一个实例在崩溃后至少不可用,而不是我们使用的最大TTL(即实例崩溃时存在的所有与锁有关的所有键)所需的时间。变得无效并被自动释放。
使用延迟重新启动,即使没有任何种类的Redis持久性,也基本上可以实现安全性,但是请注意,这可能会转化为可用性损失。例如,如果大多数实例崩溃,则该系统将对TTL全局不可用(此处,全局意味着在此期间根本没有资源可锁定)。
使算法更可靠:扩展锁
如果客户端执行的工作由小的步骤组成,则默认情况下可以使用较小的锁有效时间,并扩展实现锁扩展机制的算法。基本上,如果在计算的中间,而锁的有效性接近较低的值,则客户端可以通过向所有扩展了密钥的TTL的实例发送Lua脚本(如果密钥存在并且其值仍然是)来扩展锁定。获取锁时客户端分配的随机值。
客户端仅应在能够在有效时间内将锁扩展到大多数实例中的情况下,才考虑重新获取锁(基本上,所使用的算法与获取锁时所使用的算法非常相似)。
但是,这在技术上并没有改变算法,因此应该限制最大的锁重新尝试获取次数,否则会破坏活动性之一。
http://dl.acm.org/citation.cfm?id=74870
http://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
http://antirez.com/news/101
很多互联网场景(如商品秒杀,论坛回帖盖楼等),需要用加锁的方式,以对某种资源进行顺序访问控制。如果应用服务集群部署,则涉及到对分布式应用加锁。
当前分布式加锁主要有三种方式:(磁盘)数据库、缓存数据库、Zookeeper。
Redis类型的缓存实例实现分布式加锁,有几大优势:
加锁操作简单,使用SET、GET、DEL等几条简单命令即可实现锁的获取和释放。
性能优越,缓存数据的读写优于磁盘数据库与Zookeeper。
加锁访问
import java.util.UUID;
import redis.clients.jedis.Jedis;
public class DistributedLock {
private final String host = "192.168.0.220";
private final int port = 6379;
private static final String SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String EXPIRE_TIME = "PX";
public DistributedLock(){}
/*
* @param lockName 锁名
* @param timeout 获取锁的超时时间
* @param lockTimeout 锁的有效时间
* @return 锁的标识
*/
public String getLockWithTimeout(String lockName, long timeout, long lockTimeout) {
String ret = null;
Jedis jedisClient = new Jedis(host, port);
try {
String authMsg = jedisClient.auth("******");
if (!SUCCESS.equals(authMsg)) {
System.out.println("AUTH FAILED: " + authMsg);
}
String identifier = UUID.randomUUID().toString();
String lockKey = "DLock:" + lockName;
long end = System.currentTimeMillis() + timeout;
while(System.currentTimeMillis() < end) {
String result = jedisClient.set(lockKey, identifier, SET_IF_NOT_EXIST, EXPIRE_TIME, lockTimeout);
if(SUCCESS.equals(result)) {
ret = identifier;
break;
}
try {
Thread.sleep(2);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
catch (Exception e) {
e.printStackTrace();
}finally {
jedisClient.quit();
jedisClient.close();
}
return ret;
}
/*
* @param lockName 锁名
* @param identifier 锁的标识
*/
public void releaseLock(String lockName, String identifier) {
Jedis jedisClient = new Jedis(host, port);
try {
String authMsg = jedisClient.auth("******");
if (!SUCCESS.equals(authMsg)) {
System.out.println("AUTH FAILED: " + authMsg);
}
String lockKey = "DLock:" + lockName;
if(identifier.equals(jedisClient.get(lockKey))) {
jedisClient.del(lockKey);
}
}
catch (Exception e) {
e.printStackTrace();
}finally {
jedisClient.quit();
jedisClient.close();
}
}
}
假设20个线程对10台mate10手机进行抢购:
import java.util.UUID;
public class CaseTest {
public static void main(String[] args) {
ServiceOrder service = new ServiceOrder();
for (int i = 0; i < 20; i++) {
ThreadBuy client = new ThreadBuy(service);
client.start();
}
}
}
class ServiceOrder {
private final int MAX = 10;
DistributedLock DLock = new DistributedLock();
int n = 10;
public void handleOder() {
String userName = UUID.randomUUID().toString().substring(0,8) + Thread.currentThread().getName();
String identifier = DLock.getLockWithTimeout("Huawei Mate 10", 10000, 2000);
System.out.println("正在为用户:" + userName + " 处理订单");
if(n > 0) {
int num = MAX - n + 1;
System.out.println("用户:"+ userName + "购买第" + num + "台,剩余" + (--n) + "台");
}else {
System.out.println("用户:"+ userName + "无法购买,已售罄!");
}
DLock.releaseLock("Huawei Mate 10", identifier);
}
}
class ThreadBuy extends Thread {
private ServiceOrder service;
public ThreadBuy(ServiceOrder service) {
this.service = service;
}
@Override
public void run() {
service.handleOder();
}
}
正在为用户:eee56fb7Thread-16 处理订单
用户:eee56fb7Thread-16购买第1台,剩余9台
正在为用户:d6521816Thread-2 处理订单
用户:d6521816Thread-2购买第2台,剩余8台
正在为用户:d7b3b983Thread-19 处理订单
用户:d7b3b983Thread-19购买第3台,剩余7台
正在为用户:36a6b97aThread-15 处理订单
用户:36a6b97aThread-15购买第4台,剩余6台
正在为用户:9a973456Thread-1 处理订单
用户:9a973456Thread-1购买第5台,剩余5台
正在为用户:03f1de9aThread-14 处理订单
用户:03f1de9aThread-14购买第6台,剩余4台
正在为用户:2c315ee6Thread-11 处理订单
用户:2c315ee6Thread-11购买第7台,剩余3台
正在为用户:2b03b7c0Thread-12 处理订单
用户:2b03b7c0Thread-12购买第8台,剩余2台
正在为用户:75f25749Thread-0 处理订单
用户:75f25749Thread-0购买第9台,剩余1台
正在为用户:26c71db5Thread-18 处理订单
用户:26c71db5Thread-18购买第10台,剩余0台
正在为用户:c32654dbThread-17 处理订单
用户:c32654dbThread-17无法购买,已售罄!
正在为用户:df94370aThread-7 处理订单
用户:df94370aThread-7无法购买,已售罄!
正在为用户:0af94cddThread-5 处理订单
用户:0af94cddThread-5无法购买,已售罄!
正在为用户:e52428a4Thread-13 处理订单
用户:e52428a4Thread-13无法购买,已售罄!
正在为用户:46f91208Thread-10 处理订单
用户:46f91208Thread-10无法购买,已售罄!
正在为用户:e0ca87bbThread-9 处理订单
用户:e0ca87bbThread-9无法购买,已售罄!
正在为用户:f385af9aThread-8 处理订单
用户:f385af9aThread-8无法购买,已售罄!
正在为用户:46c5f498Thread-6 处理订单
用户:46c5f498Thread-6无法购买,已售罄!
正在为用户:935e0f50Thread-3 处理订单
用户:935e0f50Thread-3无法购买,已售罄!
正在为用户:d3eaae29Thread-4 处理订单
用户:d3eaae29Thread-4无法购买,已售罄!
不加锁场景
如果注释掉加锁代码,变成无锁情况,则抢购无序
//测试类中注释两行用于加锁的代码:
public void handleOder() {
String userName = UUID.randomUUID().toString().substring(0,8) + Thread.currentThread().getName();
//加锁代码
//String identifier = DLock.getLockWithTimeout("Huawei Mate 10", 10000, 2000);
System.out.println("正在为用户:" + userName + " 处理订单");
if(n > 0) {
int num = MAX - n + 1;
System.out.println("用户:"+ userName + "够买第" + num + "台,剩余" + (--n) + "台");
}else {
System.out.println("用户:"+ userName + "无法够买,已售罄!");
}
//加锁代码
//DLock.releaseLock("Huawei Mate 10", identifier);
}
Redisson实现分布式锁
Redisson它除了常规的操作命令以外,还基于redis本身的特性去实现了很多功能的封装,比如分布式锁、原子操
作、布隆过滤器、队列等等。我们可以直接利用这个api提供的功能去实现
Config config=new Config();
config.useSingleServer().setAddress("redis://192.168.11.152:6379");
RedissonClient redissonClient=Redisson.create(config);
RLock rLock=redissonClient.getLock("updateOrder");
//最多等待100秒、上锁10s以后自动解锁
if(rLock.tryLock(100,10,TimeUnit.SECONDS)){
System.out.println("获取锁成功");
}
通过lua脚本来实现加锁的操作
\1. 判断lock键是否存在,不存在直接调用hset存储当前线程信息并且设置过期时间,返回nil,告诉客户端直接获取
到锁。
\2. 判断lock键是否存在,存在则将重入次数加1,并重新设置过期时间,返回nil,告诉客户端直接获取到锁。
\3. 被其它线程已经锁定,返回锁有效期的剩余时间,告诉客户端需要等待。\1. 如果lock键不存在,发消息说锁已经可用,发送一个消息
\2. 如果锁不是被当前线程锁定,则返回nil
\3. 由于支持可重入,在解锁时将重入次数需要减1
\4. 如果计算后的重入次数>0,则重新设置过期时间
\5. 如果计算后的重入次数<=0,则发消息说锁已经可用
管道模式
Redis服务是一种C/S模型,提供请求-响应式协议的TCP服务,所以当客户端发起请求,服务端处理并返回结果到
客户端,一般是以阻塞形式等待服务端的响应,但这在批量处理连接时延迟问题比较严重,所以Redis为了提升或
弥补这个问题,引入了管道技术:可以做到服务端未及时响应的时候,客户端也可以继续发送命令请求,做到客户
端和服务端互不影响,服务端并最终返回所有服务端的响应,大大提高了C/S模型交互的响应速度上有了质的提高
使用方法
Jedis jedis=new Jedis("192.168.11.152",6379);
Pipeline pipeline=jedis.pipelined();
for(int i=0;i<1000;i++){
pipeline.incr("test");
}
pipeline.sync();
使用Redis实现排行榜功能
场景介绍
在网页和APP中常常需要用到榜单的功能,对某个key-value的列表进行降序显示。当操作和查询并发大的时候,使用传统数据库就会遇到性能瓶颈,造成较大的时延。
Redis,可以实现一个商品热销排行榜的功能。它的优势在于:
数据保存在缓存中,读写速度非常快。
提供字符串(String)、链表(List)、集合(Set)、哈希(Hash)等多种数据结构类型的存储。
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Tuple;
public class productSalesRankDemo {
static final int PRODUCT_KINDS = 30;
public static void main(String[] args) {
//实例连接地址,从控制台获取
String host = "192.168.0.246";
//Redis端口
int port = 6379;
Jedis jedisClient = new Jedis(host, port);
try {
//实例密码
String authMsg = jedisClient.auth("******");
if (!authMsg.equals("OK")) {
System.out.println("AUTH FAILED: " + authMsg);
}
//键
String key = "商品热销排行榜";
jedisClient.del(key);
//随机生成产品数据
List<String> productList = new ArrayList<>();
for(int i = 0; i < PRODUCT_KINDS; i ++) {
productList.add("product-" + UUID.randomUUID().toString());
}
//随机生成销量
for(int i = 0; i < productList.size(); i ++) {
int sales = (int)(Math.random() * 20000);
String product = productList.get(i);
//插入Redis的SortedSet中
jedisClient.zadd(key, sales, product);
}
System.out.println();
System.out.println(" "+key);
//获取所有列表并按销量顺序输出
Set<Tuple> sortedProductList = jedisClient.zrevrangeWithScores(key, 0, -1);
for(Tuple product : sortedProductList) {
System.out.println("产品ID: " + product.getElement() + ", 销量: "
+ Double.valueOf(product.getScore()).intValue());
}
System.out.println();
System.out.println(" "+key);
System.out.println(" 前五大热销产品");
//获取销量前五列表并输出
Set<Tuple> sortedTopList = jedisClient.zrevrangeWithScores(key, 0, 4);
for(Tuple product : sortedTopList) {
System.out.println("产品ID: " + product.getElement() + ", 销量: "
+ Double.valueOf(product.getScore()).intValue());
}
}
catch (Exception e) {
e.printStackTrace();
}
finally {
jedisClient.quit();
jedisClient.close();
}
}
}
Troubleshooting
使用Jedis连接池报错如何处理?
在使用Jedis连接池JedisPool模式下,比较常见的报错如下:
redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
首先确认DCS缓存实例是正常运行中状态,然后按以下步骤进行排查。
网络
核对IP地址配置
检查jedis客户端配置的ip地址是否与DCS缓存实例配置的子网地址一致,如果从公网访问,则检查是否与DCS缓存实例绑定的弹性ip地址一致,不一致则修改一致后重试。
测试网络
在客户端使用ping和Telnet小工具测试网络。
如果ping不通:
VPC内访问时,要求客户端与DCS缓存实例的VPC相同,安全组相同或者DCS缓存实例的安全组放开了6379端口访问。
公网SSL方式访问时,要求DCS缓存实例安全组放开了36379端口访问。
公网直接访问(非SSL方式)时,要求DCS缓存实例安全组放开了6379端口访问。
如果IP地址可以ping通,telnet对应的端口不通,则尝试重启实例,如重启后仍未恢复,请联系技术支持。
检查连接数是否超限
查看已建立的网络连接数是否超过JedisPool 配置的上限。如果连接数接近配置的上限值,则建议重启服务观察。如果明显没有接近,排除连接数超限可能。
Unix/Linux系统使用:
netstat -an | grep 6379 | grep ESTABLISHED | wc -l
Windows系统使用:
netstat -an | find “6379” | find “ESTABLISHED” /C
检查JedisPool连接池代码
如果连接数接近配置的上限,请分析是业务并发原因,或是没有正确使用JedisPool所致。
对于JedisPool连接池的操作,每次调用jedisPool.getResource()方法之后,需要调用jedisPool.returnResource()或者jedis.close()进行释放,优先使用close()方法。
客户端TIME_WAIT是否过多
通过ss -s查看time wait链接是否过多。
如果TIME_WAIT过多,可以调整内核参数(/etc/sysctl.conf):
##当出现SYN等待队列溢出时,启用cookies来处理,可防范少量SYN攻击
net.ipv4.tcp_syncookies = 1
##允许将TIME-WAIT sockets重新用于新的TCP连接
net.ipv4.tcp_tw_reuse = 1
##开启TCP连接中TIME-WAIT sockets的快速回收
net.ipv4.tcp_tw_recycle = 1
##修改系統默认的TIMEOUT时间
net.ipv4.tcp_fin_timeout = 30
调整后重启生效:/sbin/sysctl -p
无法解决问题
如果按照以上原因排查之后还有问题,可以通过抓包并将异常时间点、异常信息以及抓包文件发送给技术支持协助分析。
抓包可使用tcpdump工具,命令如下:
tcpdump -i eth0 tcp and port 6379 -n -nn -s 74 -w dump.pcap
Windows系统下还可以安装Wireshark工具抓包。
数据迁移失败问题排查
在使用控制台进行数据迁移时,如果出现迁移方案选择错误、在线迁移源Redis没有放通SYNC和PSYNC命令、源Redis和目标Redis网络不连通等问题,都会导致迁移失败。
查看迁移日志。
出现如下错误,表示迁移任务底层资源不足,需要联系技术支持处理。
create migration ecs failed, flavor
出现如下错误,表示在线迁移时,源Redis没有放通SYNC和PSYNC命令,需要联系技术支持放通命令。
source redis unsupported command: psync
检查迁移方案是否选择正确。
根据自建Redis迁移至DCS、DCS实例间迁移、其他云厂商Redis服务迁移至DCS的不同场景,选择合适的迁移方案,例如,DCS实例间迁移,高版本不支持迁移到低版本。
迁移方案选择不正确,会导致迁移失败,具体迁移方案,请查看迁移方案介绍。
检查源Redis是否放通SYNC和PSYNC命令,迁移任务底层资源与源Redis、目标Redis网络是否连通。
如果是在线迁移,才涉及该操作。
在线迁移,必须满足源Redis和目标Redis的网络相通、源Redis已放通SYNC和PSYNC命令这两个前提,否则,会迁移失败。
网络
检查源Redis、目标Redis、迁移任务所需虚拟机是否在同一个VPC,如果是同一个VPC,则检查安全组(Redis3.0实例)或白名单(Redis4.0/5.0实例)是否放通端口和IP,确保网络是连通的;如果不在同一个VPC,则需要建立VPC对等连接,打通网络。
源Redis和目标Redis必须允许迁移任务底层虚拟机访问。实例安全组或白名单配置,请参考配置安全组、配置白名单。
命令
默认情况下,一般云厂商都是禁用了SYNC和PSYNC命令,如果要放通,需要联系云厂商运维人员放通命令。华为云DCS Redis服务,如果是相同Region进行在线迁移,在执行迁移时,会放通SYNC和PSYNC命令;如果是不同Region进行在线迁移,不支持放通SYNC和PSYNC命令,无法使用在线迁移,推荐使用备份文件导入方式迁移。
检查源Redis是否存在大Key。
如果源Redis存在大key,建议将大key打散成多个小key后再迁移。
检查目标Redis的规格是否大于迁移数据大小、是否有其他任务在执行。
如果目标Redis的实例规格小于迁移数据大小,迁移过程中,内存被占满,会导致迁移失败。
如果目标Redis存在正在执行的主备倒换,建议联系技术支持关闭主备倒换后,重新执行数据迁移。待迁移完成后,重新开启主备倒换。
检查迁移操作是否正确。
检查填写的IP地址、实例密码是否正确。
客户端连接问题
在使用Redis-cli连接Cluster集群时,连接失败。
解决方法:请检查连接命令是否加上-c,在连接Cluster集群节点时务必使用正确连接命令。
Cluster集群连接命令:
./redis-cli -h {dcs_instance_address} -p 6379 -a {password} -c
单机、主备、Proxy集群连接命令:
./redis-cli -h {dcs_instance_address} -p 6379 -a {password}
具体连接操作,请参考Redis-cli连接。
出现Read timed out或Could not get a resource from the pool
解决方法:排查是否使用了keys命令,keys命令会消耗大量资源,造成Redis阻塞。建议使用scan命令替代,且避免频繁执行。
Jedis连接池问题,请参考使用Jedis连接池报错如何处理?。
带宽超限导致连接问题
当实例已使用带宽达到实例规格最大带宽,可能会导致部分Redis连接超时现象。
您可以查看监控指标“流控次数”,统计周期内被流控的次数,确认带宽是否已经达到上限。
然后,检查实例是否有大Key和热Key,如果存在大Key或者单个Key负载过大,容易造成对于单个Key的操作占用带宽资源过高。大Key和热Key操作,请参考分析实例大Key和热Key。
性能问题导致连接超时
使用了keys等消耗资源的命令,导致CPU使用率超高;或者实例没有设置过期时间、没有清除已过期的Key,导致存储的数据过多,一直在内存中,内存使用率过高等,这些都容易出现访问缓慢、连接不上等情况。
建议客户改成scan命令或者禁用keys命令。
查看监控指标,并配置对应的告警。监控项和配置告警步骤,可查看必须配置的监控告警。
例如,可以通过监控指标“内存利用率”和“已用内存”查看实例内存使用情况、“活跃的客户端数量”查看实例连接数是否达到上限等。
问题一:无法从连接池获取到Jedis连接
异常堆栈
当blockWhenExhausted连接池参数等于true(默认值)时,如果连接池没有可用的Jedis连接,则会等待一段时间,等待的时间由maxWaitMillis参数决定,单位为毫秒,如果依然没有获取到可用的Jedis连接,才会出现下列异常。
redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
…
Caused by: java.util.NoSuchElementException: Timeout waiting for idle object
at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:449)
当blockWhenExhausted连接池参数等于false时,如果连接池没有可用的Jedis连接,则会立即出现下列异常。
redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
…
Caused by: java.util.NoSuchElementException: Pool exhausted
at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:464)
异常描述
上述异常是客户端没有从连接池获得可用的Jedis连接造成,Jedis资源最大数量由maxTotal值决定,可能有下列几种原因。
连接泄露
JedisPool默认的maxTotal值为8,从下列代码得知,从JedisPool中获取了8个Jedis资源,但是没有归还资源。因此,当第9次尝试获取Jedis资源的时候,则无法调用jedisPool.getResource().ping()。
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
JedisPool jedisPool = new JedisPool(poolConfig, “127.0.0.1”, 6379);
//向JedisPool借用8次连接,但是没有执行归还操作。
for (int i = 0; i < 8; i++) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
jedis.ping();
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
}
jedisPool.getResource().ping();
推荐使用下列规范代码。
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
//具体的命令
jedis.executeCommand()
} catch (Exception e) {
//如果命令有key最好把key也在错误日志打印出来,对于集群版来说通过key可以帮助定位到具体节点。
logger.error(e.getMessage(), e);
} finally {
//注意这里不是关闭连接,在JedisPool模式下,Jedis会被归还给资源池。
if (jedis != null)
jedis.close();
}
业务并发量大(maxTotal值设置得过小)
业务并发量大导致出现异常的示例:一次命令运行时间(borrow|return resource + Jedis执行命令 + 网络时间)的平均耗时约为1ms,一个连接的QPS大约是1000。业务期望的QPS是50000。那么理论上需要的资源池大小为50000除以1000等于50个。因此用户需要根据实际情况对maxTotal值进行微调。
Jedis连接阻塞
例如Redis发生了阻塞(例如慢查询等原因),所有连接会在超时时间范围内等待,当并发量较大时,会造成连接池资源不足。
Jedis连接被拒绝
从连接池中获取连接时,由于没有空闲连接,需要重新生成一个Jedis连接,但是连接被拒绝。
redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
at redis.clients.util.Pool.getResource(Pool.java:50)
at redis.clients.jedis.JedisPool.getResource(JedisPool.java:99)
at TestAdmin.main(TestAdmin.java:14)
Caused by: redis.clients.jedis.exceptions.JedisConnectionException: java.net.ConnectException: Connection refused
at redis.clients.jedis.Connection.connect(Connection.java:164)
at redis.clients.jedis.BinaryClient.connect(BinaryClient.java:80)
at redis.clients.jedis.BinaryJedis.connect(BinaryJedis.java:1676)
at redis.clients.jedis.JedisFactory.makeObject(JedisFactory.java:87)
at org.apache.commons.pool2.impl.GenericObjectPool.create(GenericObjectPool.java:861)
at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:435)
at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:363)
at redis.clients.util.Pool.getResource(Pool.java:48)
… 2 more
Caused by: java.net.ConnectException: Connection refused
at java.net.PlainSocketImpl.socketConnect(Native Method)
at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:339)
at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:200)
at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:182)
at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392)
at java.net.Socket.connect(Socket.java:579)
at redis.clients.jedis.Connection.connect(Connection.java:158)
… 9 more
可以从at redis.clients.jedis.Connection.connect(Connection.java:158)中看到实际是一个Socket连接。
socket.setSoLinger(true, 0); // Control calls close () method,
// the underlying socket is closed
// immediately
// <-@wjw_add
158: socket.connect(new InetSocketAddress(host, port), connectionTimeout);
提示:一般这类报错需要检查Redis的域名配置是否正确,排查该段时间网络是否正常。
其他问题
丢包、DNS、客户端TCP参数配置等,可以提交工单获取帮助。
解决方法
从上述分析,可以看出这个问题的原因比较复杂,不能简单地认为连接池不够就盲目加大maxTotal值,需要具体问题具体分析。
问题二:客户端缓冲区异常
异常堆栈
异常堆栈如下。
redis.clients.jedis.exceptions.JedisConnectionException: Unexpected end of stream. at redis.clients.util.RedisInputStream.ensureFill(RedisInputStream.java:199) at redis.clients.util.RedisInputStream.readByte(RedisInputStream.java:40) at redis.clients.jedis.Protocol.process(Protocol.java:151) …
异常描述
这个异常描述是客户端缓冲区异常,产生这个问题可能有下列三个原因。
多个线程使用一个Jedis连接
正常的情况是一个线程使用一个Jedis连接,可以使用JedisPool管理Jedis连接,实现线程安全,避免出现这种情况。例如,下面代码就是两个线程共用了一个Jedis连接。
new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 100; i++) {
jedis.get(“hello”);
}
}
}).start();
new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 100; i++) {
jedis.hget(“haskey”, “f”);
}
}
}).start();
客户端缓冲区满了
Redis有下列三种客户端缓冲区。
普通客户端缓冲区(normal):用于接受普通的命令,例如get、set、mset、hgetall、zrange等。
slave客户端缓冲区(slave):用于同步master节点的写命令,完成复制。
发布订阅缓冲区(pubsub):pubsub不是普通的命令,因此有单独的缓冲区。
Redis客户端缓冲区配置的格式如下。
client-output-buffer-limit [
C
l
a
s
s
]
[
Class] [
Class][Hard_Limit] [
S
o
f
t
L
i
m
i
t
]
[
Soft_Limit] [
SoftLimit][Soft_Seconds]
[
C
l
a
s
s
]
:
客
户
端
类
型
,
可
选
值
为
n
o
r
m
a
l
、
s
l
a
v
e
和
p
u
b
s
u
b
。
[
Class]:客户端类型,可选值为normal、slave和pubsub。 [
Class]:客户端类型,可选值为normal、slave和pubsub。[Hard_Limit]:如果客户端使用的输出缓冲区大于hard limit,客户端会被立即关闭,单位为秒。
[
S
o
f
t
L
i
m
i
t
]
和
[
Soft_Limit]和[
SoftLimit]和[Soft_Seconds]:如果客户端使用的输出缓冲区超过了soft limit,并且持续了soft limit秒,客户端会被立即关闭,单位为秒。
例如下面是一份Redis缓冲区的配置,所以当条件满足时,客户端连接会被关闭,就会出现Unexpected end of stream报错。
redis> config get client-output-buffer-limit
- “client-output-buffer-limit”
- “normal 524288000 0 0 slave 2147483648 536870912 480 pubsub 33554432 8388608 60”
长时间闲置连接
长时间闲置连接会被服务端主动断开,可以查询timeout配置的设置以及自身连接池配置确定是否需要做空闲检测。
解决方法和处理途径
排查自身代码是否使用JedisPool管理Jedis连接,是否存在并发操作Jedis的情况。
排查是否是上述客户端缓冲区满了或长时间闲置连接原因。云数据库Redis版默认的timeout值为0,目前不支持修改。client-output-buffer-limit默认值为500MB,为阿里云优化后的合理值。如果超过该值,说明用户返回的值过多,出于性能和稳定性考虑,建议优化应用程序。
问题三:非法的客户端地址
提示:阿里云Redis提供客户端白名单功能。
异常堆栈
异常堆栈如下。
Caused by: redis.clients.jedis.exceptions.JedisDataException: ERR illegal address
at redis.clients.jedis.Protocol.processError(Protocol.java:117)
at redis.clients.jedis.Protocol.process(Protocol.java:151)
at redis.clients.jedis.Protocol.read(Protocol.java:205)
…
向集合添加一个成员时,例如使用SET TEST "Helloworld"命令,出现下列报错,也可能是白名单问题。
Error: Insert the diskette for drive %1.
异常描述
Redis实例配置了白名单,但当前访问Redis的客户端(IP)不在白名单中。
解决方法
添加该客户端(IP)的白名单,关于如何添加白名单,请参考设置IP白名单。
问题四:客户端连接数达到最大值
异常堆栈
异常堆栈如下。
redis.clients.jedis.exceptions.JedisDataException: ERR max number of clients reached
异常描述
客户端连接数超过了Redis实例配置的最大maxclients。
解决方法
提交工单联系阿里云技术支持临时调整最大连接数,协助定位问题。
定位自身问题,可以定位连接最多的客户端,找到问题原因,例如连接池配置等,然后进行处理。
问题五:客户端读写超时
异常堆栈
redis.clients.jedis.exceptions.JedisConnectionException: java.net.SocketTimeoutException: Read timed out
异常描述
问题原因可能有下列几种。
读写超时设置的过短。
有慢查询或者Redis发生阻塞。
网络不稳定。
解决方法
用户提供读写超时时间,提交工单定位相关原因并解决。
问题六:密码相关的异常
异常堆栈
Redis设置了密码鉴权,客户端请求没有提供密码。
Exception in thread “main” redis.clients.jedis.exceptions.JedisDataException: NOAUTH Authentication required.
at redis.clients.jedis.Protocol.processError(Protocol.java:127)
at redis.clients.jedis.Protocol.process(Protocol.java:161)
at redis.clients.jedis.Protocol.read(Protocol.java:215)
Redis没有设置密码鉴权,客户端请求中包含了密码。
Exception in thread “main” redis.clients.jedis.exceptions.JedisDataException: ERR Client sent AUTH, but no password is set
at redis.clients.jedis.Protocol.processError(Protocol.java:127)
at redis.clients.jedis.Protocol.process(Protocol.java:161)
at redis.clients.jedis.Protocol.read(Protocol.java:215)
客户端传输了错误的密码。
redis.clients.jedis.exceptions.JedisDataException: ERR invalid password
at redis.clients.jedis.Protocol.processError(Protocol.java:117)
at redis.clients.jedis.Protocol.process(Protocol.java:151)
at redis.clients.jedis.Protocol.read(Protocol.java:205)
解决方法
确认有没有设置密码鉴权,是否提供了正确的密码。
问题七:事务异常
异常堆栈
异常堆栈如下。
redis.clients.jedis.exceptions.JedisDataException: EXECABORT Transaction discarded because of previous errors
异常描述
这个是Redis的事务异常,事务中包含了错误的命令,例如,下列sett是个不存在的命令。
127.0.0.1:6379> multi
OK
127.0.0.1:6379> sett key world
(error) ERR unknown command ‘sett’
127.0.0.1:6379> incr counter
QUEUED
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
解决方法
查看自身代码逻辑,修复代码错误。
问题八:类转换错误
异常堆栈
异常堆栈如下。
java.lang.ClassCastException: java.lang.Long cannot be cast to java.util.List
at redis.clients.jedis.Connection.getBinaryMultiBulkReply(Connection.java:199)
at redis.clients.jedis.Jedis.hgetAll(Jedis.java:851)
at redis.clients.jedis.ShardedJedis.hgetAll(ShardedJedis.java:198)
java.lang.ClassCastException: java.util.ArrayList cannot be cast to [B
at redis.clients.jedis.Connection.getBinaryBulkReply(Connection.java:182)
at redis.clients.jedis.Connection.getBulkReply(Connection.java:171)
at redis.clients.jedis.Jedis.rpop(Jedis.java:1109)
at redis.clients.jedis.ShardedJedis.rpop(ShardedJedis.java:258)
…
异常描述
Jedis正确的使用方法是,一个线程操作一个Jedis,如果多个线程操作同一个Jedis连接就会发生此类错误。使用JedisPool可避免此类问题。例如下列代码在两个线程并发使用了一个Jedis(get、hgetAll返回不同的类型)。
new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 100; i++) {
jedis.set(“hello”, “world”);
jedis.get(“hello”);
}
}
}).start();
new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 100; i++) {
jedis.hset(“hashkey”, “f”, “v”);
jedis.hgetAll(“hashkey”);
}
}
}).start();
解决方法和处理途径
用户排查自身代码是否存在问题。
问题九:命令使用错误
异常堆栈
异常堆栈如下。
Exception in thread “main” redis.clients.jedis.exceptions.JedisDataException: WRONGTYPE Operation against a key holding the wrong kind of value
at redis.clients.jedis.Protocol.processError(Protocol.java:127)
at redis.clients.jedis.Protocol.process(Protocol.java:161)
at redis.clients.jedis.Protocol.read(Protocol.java:215)
…
异常描述
例如key=”hello”是字符串类型的键,而hgetAll返回哈希类型的键,所以出现了错误。
jedis.set(“hello”,“world”);
jedis.hgetAll(“hello”);
解决方法和处理途径
请用户修改自身代码错误。
问题十:Redis使用的内存超过maxmemory配置
异常堆栈
Redis节点(如果是集群,则是其中一个节点)使用内存大于该实例的内存规格(maxmemory配置)。异常堆栈如下。
redis.clients.jedis.exceptions.JedisDataException: OOM command not allowed when used memory > ‘maxmemory’.
问题原因
原因可能有下列几种。
业务数据正常增加。
客户端缓冲区异常,例如monitor、pub/sub使用不当等等。
纯缓存使用场景,但是maxmemory-policy配置有误(例如没有设置过期键的业务配置volatile-lru)。
解决方法
确认内存增大的原因,根据自身业务场景解决问题。
需要紧急处理时,可以临时调整maxmeory,后续咨询是否需要升配或者调整配置。
问题十一:Redis正在加载持久化文件
异常堆栈
异常堆栈如下。
redis.clients.jedis.exceptions.JedisDataException: LOADING Redis is loading the dataset in memory
异常描述
Jedis调用Redis时,如果Redis正在加载持久化文件,无法进行正常的读写。
解决方法
正常情况下,阿里云Redis不会出现这种情况,如果出现,则提交工单。
问题十二:Lua脚本超时
异常堆栈
异常堆栈如下。
redis.clients.jedis.exceptions.JedisDataException: BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
异常描述
如果Redis当前正在执行Lua脚本,并且超过了lua-time-limit,此时Jedis调用Redis时,会收到上述异常。
解决方法
按照异常提示You can only call SCRIPT KILL or SHUTDOWN NOSAVE.,使用kill命令终止Lua脚本。
问题十三:连接超时
异常堆栈
redis.clients.jedis.exceptions.JedisConnectionException: java.net.SocketTimeoutException: connect timed out
异常原因
可能原因有下列几种。
连接超时设置的过短。
tcp-backlog满,造成新的连接失败。
客户端与服务端网络故障。
解决方法
用户提供连接超时时间,提交工单定位相关原因。
问题十四:Lua脚本写超时
异常堆栈
异常堆栈如下。
(error) UNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in a hard way using the SHUTDOWN NOSAVE command.
异常描述
如果Redis当前正在执行Lua脚本,并且超过了lua-time-limit,并且已经执行过写命令,此时Jedis调用Redis时,会收到上述异常。
解决方法
提交工单紧急处理,管理员需要重启或者切换Redis节点。
问题十五:类加载错误
异常堆栈
找不到类和方法的异常堆栈如下。
Exception in thread “commons-pool-EvictionTimer” java.lang.NoClassDefFoundError: redis/clients/util/IOUtils
at redis.clients.jedis.Connection.disconnect(Connection.java:226)
at redis.clients.jedis.BinaryClient.disconnect(BinaryClient.java:941)
at redis.clients.jedis.BinaryJedis.disconnect(BinaryJedis.java:1771)
at redis.clients.jedis.JedisFactory.destroyObject(JedisFactory.java:91)
at org.apache.commons.pool2.impl.GenericObjectPool.destroy(GenericObjectPool.java:897)
at org.apache.commons.pool2.impl.GenericObjectPool.evict(GenericObjectPool.java:793)
at org.apache.commons.pool2.impl.BaseGenericObjectPool$Evictor.run(BaseGenericObjectPool.java:1036)
at java.util.TimerThread.mainLoop(Timer.java:555)
at java.util.TimerThread.run(Timer.java:505)
Caused by: java.lang.ClassNotFoundException: redis.clients.util.IOUtils
…
异常描述
运行时,Jedis执行命令,抛出异常,提示某个类找不到。此类问题一般都是由于加载多个jedis版本(例如jedis 2.9.0和jedis 2.6),在编译期间代码未出现问题,但类加载器在运行时加载了低版本的Jedis,造成运行时找不到类。
解决方法
通常此类问题,可以将重复的Jedis排除掉,例如利用maven的依赖树,把无用的依赖去掉或者exclusion掉。
服务端命令不支持
异常堆栈
例如客户端执行了geoadd命令,但是服务端返回不支持此命令。
redis.clients.jedis.exceptions.JedisDataException: ERR unknown command ‘GEOADD’
异常描述
该命令不能被Redis端识别,可能有下列两个原因。
社区版的一些命令,阿里云Redis不支持,或者只在某些小版本上支持,例如geoadd是Redis 3.2添加的地理信息API。
命令本身是错误的,不过对于Jedis来说不能识别,Jedis不支持直接使用命令,每个API都有固定的函数。
解决方法
咨询是否有Redis版本支持该命令,如支持可以让客户做小版本升级。
pipeline错误使用
redis.clients.jedis.exceptions.JedisDataException: Please close pipeline or multi block before calling this method.
异常描述
在pipeline.sync()执行之前,通过response.get()获取值,在pipeline.sync()执行前,命令没有执行(可以通过monitor做验证),下面代码就会引起上述异常。
Jedis jedis = new Jedis(“127.0.0.1”, 6379);
Pipeline pipeline = jedis.pipelined();
pipeline.set(“hello”, “world”);
pipeline.set(“java”, “jedis”);
Response pipeString = pipeline.get(“java”);
//这个get必须在sync之后,如果是批量获取值建议直接用List objectList = pipeline.syncAndReturnAll();
System.out.println(pipeString.get());
//命令此时真正执行
pipeline.sync();
Jedis中Reponse的get()方法,判断代码如下,如果set=false就会报错,而response中的set初始化为false。
public T get() {
// if response has dependency response and dependency is not built,
// build it first and no more!!
if (dependency != null && dependency.set && !dependency.built) {
dependency.build();
}
if (!set) {
throw new JedisDataException(
“Please close pipeline or multi block before calling this method.”);
}
if (!built) {
build();
}
if (exception != null) {
throw exception;
}
return response;
}
pipeline.sync()代码会将每个运行结果设置set=true,如下所示。
public void sync() {
if (getPipelinedResponseLength() > 0) {
List unformatted = client.getAll();
for (Object o : unformatted) {
generateResponse(o);
}
}
}
其中generateResponse(o)代码如下。
protected Response<?> generateResponse(Object data) {
Response<?> response = pipelinedResponses.poll();
if (response != null) {
response.set(data);
}
return response;
}
其中response.set(data)代码如下。
public void set(Object data) {
this.data = data;
set = true;
}
解决方法
对于批量结果的解析,建议使用pipeline.syncAndReturnAll()来实现,下面操作模拟了批量hgetAll。
/**
- pipeline模拟批量hgetAll
- @param keyList
- @return
*/
public Map<String, Map<String, String>> mHgetAll(List keyList) {
// 1.生成pipeline对象
Pipeline pipeline = jedis.pipelined();
// 2.pipeline执行命令,注意此时命令并未真正执行
for (String key : keyList) {
pipeline.hgetAll(key);
}
// 3.执行命令 syncAndReturnAll()返回结果
List objectList = pipeline.syncAndReturnAll();
if (objectList == null || objectList.isEmpty()) {
return Collections.emptyMap();
}
// 4.解析结果
Map<String,Map<String, String>> resultMap = new HashMap<String, Map<String,String>>();
for (int i = 0; i < objectList.size(); i++) {
Object object = objectList.get(i);
Map<String, String> map = (Map<String, String>) object;
String key = keyList.get(i);
resultMap.put(key, map);
}
return resultMap;
}
处理途径
检查并修改业务代码。
管理员命令普通用户不能执行
异常堆栈
命令role不能被普通用户执行,详情可参考暂未开放的Redis命令。
redis.clients.jedis.exceptions.JedisDataException: ERR command role not support for normal user
异常描述
该命令尚未开放。
解决方法
不能使用该命令,如果有需求或者疑问可以提交工单。
Jedis版本如何选择
原则上选择最新的release版本,但最好选择发行一段时间后的版本,因为jedis历史上出现过一次问题较大的release版本,目前来说2.9.0比较稳定。
备份恢复
Redis提供了不同范围的持久性选项:
RDB(Redis数据库):RDB持久性按指定的时间间隔执行数据集的时间点快照。
AOF(仅附加文件):AOF持久性记录服务器接收的每个写入操作,这些操作将在服务器启动时再次播放,以重建原始数据集。使用与Redis协议本身相同的格式记录命令,并且采用仅追加方式。当日志太大时,Redis可以在后台重写日志。
无持久性:如果希望,只要服务器正在运行,数据就一直存在,则可以完全禁用持久性。
RDB + AOF:可以在同一实例中同时合并AOF和RDB。请注意,在这种情况下,当Redis重新启动时,AOF文件将用于重建原始数据集,因为它可以保证是最完整的。
要理解的最重要的事情是RDB与AOF持久性之间的不同权衡。让我们从RDB开始:
RDB的优势
RDB是您的Redis数据的非常紧凑的单文件时间点表示。RDB文件非常适合备份。例如,您可能希望在最近的24小时内每小时存档一次RDB文件,并在30天之内每天保存一次RDB快照。这使您可以在发生灾难时轻松还原数据集的不同版本。
RDB对于灾难恢复非常有用,它是一个紧凑的文件,可以传输到远程数据中心或Amazon S3(可能已加密)上。
RDB最大限度地提高了Redis的性能,因为Redis父进程为了持久化而需要做的唯一工作就是分叉一个孩子,其余所有工作都要做。父实例将永远不会执行磁盘I / O或类似操作。
与AOF相比,RDB允许使用大型数据集更快地重新启动。
在副本上,RDB支持重新启动和故障转移后的部分重新同步。
RDB的缺点
如果您需要最大程度地减少数据丢失的可能性(如果Redis停止工作,例如在断电之后),则RDB不好。您可以在生成RDB的位置配置不同的保存点(例如,在至少五分钟之后,对数据集进行100次写入,但是您可以有多个保存点)。但是,通常会每隔五分钟或更长时间创建一次RDB快照,因此如果Redis出于任何原因在没有正确关闭的情况下停止工作,则应该准备丢失最新的数据分钟。
RDB需要经常使用fork()才能使用子进程将其持久化在磁盘上。如果数据集很大,Fork()可能很耗时,并且如果数据集很大且CPU性能不佳,则可能导致Redis停止为客户端服务几毫秒甚至一秒钟。AOF还需要fork(),但是您可以调整要重写日志的频率,而无需在持久性上进行权衡。
AOF的优势
使用AOF Redis更加持久:您可以有不同的fsync策略:完全没有fsync,每秒fsync,每个查询fsync。使用fsync的默认策略,每秒的写入性能仍然很好(fsync是使用后台线程执行的,并且当没有fsync进行时,主线程将尽力执行写入操作。)但是您只能损失一秒钟的写入时间。
AOF日志是仅追加的日志,因此,如果断电,则不会出现寻道,也不会出现损坏问题。即使由于某种原因(磁盘已满或其他原因)以半写命令结束日志,redis-check-aof工具也可以轻松修复它。
Redis太大时,Redis可以在后台自动重写AOF。重写是完全安全的,因为Redis继续追加到旧文件时,会生成一个全新的文件,其中包含创建当前数据集所需的最少操作集,一旦准备好第二个文件,Redis会切换这两个文件并开始追加到新的那一个。
AOF以易于理解和解析的格式包含所有操作的日志。您甚至可以轻松导出AOF文件。例如,即使您不小心使用FLUSHALL命令刷新了所有内容,只要在此期间未执行日志重写,您仍然可以通过停止服务器,删除最新命令并重新启动Redis来保存数据集再次。
AOF的缺点
对于同一数据集,AOF文件通常大于等效的RDB文件。
根据确切的fsync策略,AOF可能比RDB慢。通常,在将fsync设置为每秒的情况下,性能仍然很高,并且在禁用fsync的情况下,即使在高负载下,它也应与RDB一样快。即使在巨大的写负载的情况下,RDB仍然能够提供有关最大延迟的更多保证。
过去,我们在特定命令中遇到过罕见的错误(例如,其中一个涉及阻止命令,例如BRPOPLPUSH),导致产生的AOF在重载时无法重现完全相同的数据集。这些错误很少见,我们在测试套件中进行了测试,自动创建了随机的复杂数据集,然后重新加载它们以检查一切是否正常。但是,对于RDB持久性来说,这类错误几乎是不可能的。更明确地说:Redis AOF通过增量更新现有状态来工作,就像MySQL或MongoDB一样,而RDB快照一次又一次地创建所有内容,从概念上讲更健壮。但是-1)应当注意,每次Redis重写AOF时,都会从数据集中包含的实际数据开始从头开始重新创建AOF,与始终附加AOF文件(或重写为读取旧AOF而不是读取内存中的数据)相比,提高了对错误的抵抗力。2)我们从未收到过来自用户的关于真实环境中检测到的AOF损坏的单个报告。
RDB
RDB持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘,实际操作过程是fork一个子进程,先将数据集写入临时文件,写入成功后,再替换之前的文件,用二进制压缩存储。
用户可以通过Redis-cli客户端导出rdb文件,但是使用Redis-cli导出rdb文件依赖SYNC命令。
放通了SYNC命令的单机实例(例如Redis3.0单机实例,未禁用SYNC命令),可以通过执行以下命令,将单机实例上的数据导出:
redis-cli -h {source_redis_address} -p 6379 [-a password] --rdb {output.rdb}
禁用了SYNC命令的单机实例(例如Redis 4.0和Redis5.0单机实例,禁用了SYNC命令),建议将单机实例的数据迁移到主备实例,然后使用主备实例的备份功能。
使用redis-shake备份Redis实例
redis-shake是阿里云自研的开源工具,支持对Redis数据进行解析(decode)、恢复(restore)、备份(dump)、同步(sync/rump)。在dump模式下,redis-shake可以将Redis数据库的数据保存到RDB文件中,通过RDB文件可以实现数据恢复或者迁移。本文以使用dump模式备份云数据库Redis版实例的数据到RDB文件为例。
说明
redis-shake可通过RDB文件实现数据恢复或者迁移,详细信息请参见使用redis-shake迁移RDB文件内的数据。
如需了解更多redis-shake相关信息,请参见redis-shake Github主页或FAQ。
操作步骤
登录可以连接云数据库Redis版实例(目的端Redis)的ECS。
在ECS中下载redis-shake。
说明 建议您下载最新发布的版本。
解压redis-shake.tar.gz。
tar -xvf redis-shake.tar.gz
说明 解压获得的redis-shake为64位Linux系统所需的二进制文件,redis-shake.conf为redis-shake的配置文件,您将在下个步骤对其进行修改。
修改redis-shake配置文件,dump模式涉及的主要参数说明如下。
表 1. redis-shake dump模式参数说明
参数 说明 示例
source.address 源Redis的连接地址与服务端口。 xxxxxxxxxxxx.redis.rds.aliyuncs.com:6379
source.password_raw 源Redis的连接密码。 account:password
rdb.output 输出的RDB文件名称。 local_dump
使用如下命令进行迁移。
./redis-shake -type=dump -conf=redis-shake.conf
说明 此命令需在二进制文件redis-shake和配置文件redis-shake.conf所在的目录中执行,否则请在命令中指定正确的文件路径。
说明
日志中出现execute runner[*run.CmdDump] finished!表示RDB文件备份完成。
RDB文件名称默认为local_dump.0,可使用cat local_dump.0命令确认Redis数据是否备份成功。
redis-shake是阿里云自研的开源工具,支持对Redis数据进行解析(decode)、恢复(restore)、备份(dump)、同步(sync/rump)。在restore模式下,redis-shake可以将RDB文件中保存的数据恢复到Redis实例中,实现数据恢复或者迁移,本文以将RDB文件中的数据恢复到云数据库Redis版实例中从而实现Redis上云迁移为例。
说明 如需了解更多redis-shake相关信息,请参见redis-shake Github主页或FAQ。
操作步骤
登录可以连接云数据库Redis版实例(目的端Redis)的ECS。
在ECS中下载redis-shake。
说明 建议您下载最新发布的版本。
解压redis-shake.tar.gz。
tar -xvf redis-shake.tar.gz
说明 解压获得的redis-shake为64位Linux系统所需的二进制文件,redis-shake.conf为redis-shake的配置文件,您将在下个步骤对其进行修改。
修改配置文件redis-shake.conf,restore模式涉及的主要参数说明如下。
表 1. redis-shake restore模式参数说明
参数 说明 示例
rdb.input 备份文件(RDB文件)的路径,可使用相对路径或绝对路径。 /root/tools/RedisShake/demo.rdb
target.address 目的Redis的连接地址与端口号。 r-bp1xxxxxxxxxxxxx.redis.rds.aliyuncs.com:6379
target.password_raw 目的Redis的连接密码。 TargetPass233
说明 如使用非默认账号连接云数据库Redis版实例,密码格式为account:password。
target.db 是否将源Redis数据库中所有库的数据都迁移至目标Redis实例的指定库中,取值:
-1(默认值):不启用该功能。
0~255:启用该功能并将取值作为目标Redis实例的指定库。例如取值为0,表示将源Redis实例中所有库的数据汇总迁移至目标Redis实例的数据库0中。
说明 如果源Redis数据库为主从架构,目标Redis实例为集群架构,此场景仅会同步数据库0,其他数据库的数据不会被迁移。此时,将该参数设置为0,可将源实例的所有数据库全部迁移至目标实例的数据库0中。
-1
rewrite 如果目的Redis有与RDB文件中相同的key,是否覆盖,可选值:
true(覆盖)
false(不覆盖)
说明 默认为true,建议对目的Redis中的有效数据进行完善的备份再执行恢复。如设置为false且存在数据冲突则会出现异常提示。
true
parallel RDB文件同步中使用的并发线程数,用于提高同步性能。
说明
最小值为1。
最大值取决于服务器性能。
推荐值为64。
64
说明 其它参数如无特殊情况保持默认即可。
使用如下命令进行迁移。
./redis-shake.linux -type=restore -conf=redis-shake.conf
说明 此命令需在二进制文件redis-shake和配置文件redis-shake.conf所在的目录中执行,否则请在命令中指定正确的文件路径。
图 1. 执行示例
AOF
AOF持久化以日志的形式记录服务器所处理的每一个写、删除操作,查询操作不会记录,以文本的方式记录,可以打开文件看到详细的操作记录。
您可以使用redis-cli,通过AOF文件将自建Redis迁移到云数据库Redis版。
redis-cli是Redis原生的命令行工具。云数据库Redis版支持通过redis-cli将已有的Redis数据导入到云数据库Redis版里,实现数据的无缝迁移。另外您也可以使用DTS迁移数据。
注意事项
由于云数据库Redis版仅支持从阿里云内网访问,所以此操作方案仅在阿里云ECS上执行才生效。 若您的Redis不在阿里云ECS服务器上,您需要将原有的AOF文件复制到ECS上再执行以上操作。
redis-cli是Redis原生的命令行工具。若您在ECS上无法使用redis-cli,可以先下载安装Redis即可使用redis-cli。
操作步骤
对于在阿里云ECS上自建的Redis实例,执行如下操作:
开启现有Redis实例的AOF功能(如果实例已经启用AOF功能则忽略此步骤)。
redis-cli -h old_instance_ip -p old_instance_port config set appendonly yes
通过AOF文件将数据导入到新的云数据库Redis版实例(假定生成的AOF文件名为 appendonly.aof)。
redis-cli -h aliyun_redis_instance_ip -p 6379 -a password --pipe < appendonly.aof
注意 如果原有的Redis实例不需要一直开启AOF,可在导入完成后通过以下命令关闭。
redis-cli -h old_instance_ip -p old_instance_port config set appendonly no
您还可以通过观看以下视频快速了解如何将ECS上自建Redis迁移至云数据库Redis版,视频时长约4分钟。
限流器
在限量抢购或者限时秒杀类场景中,除了要有效应对秒杀前后的流量高峰,还需要防止发生接受的下单量超过商品限购数量的问题,云数据库Redis企业版性能增强型实例的TairString结构支持简洁高效的限流器,可以很好地解决订单超量问题。本章节介绍的方案也适用于其它需要限速或者限流的场景。
抢购限流器
TairString是Redis企业版性能增强型实例集成了阿里巴巴Tair后新增的数据结构,比原生Redis String功能更加强大,除了比特位(bit)操作外能够覆盖原生Redis String的所有功能。
TairString的EXINCRBY/EXINCRBYFLOAT命令与原生Redis String的INCRBY/INCRBYFLOAT命令功能类似,都可对value进行递增或递减运算,但EXINCRBY/EXINCRBYFLOAT支持更多选项,例如EX、NX、VER、MIN、MAX等,详细说明请参见TairString命令。本章节介绍的方案涉及MIN与MAX两个选项:
选项 说明
MIN 设置TairString value的最小值。
MAX 设置TairString value的最大值。
使用原生Redis String实现抢购,代码逻辑复杂,一旦管理不当,容易出现漏网订单,即明明商品已经抢完,却还有用户收到抢购成功的提示,造成不良影响,而使用TairString,只需要非常简单的代码即可实现严谨的订单数量限制,伪代码如下:
if(EXINCRBY(key_iphone, -1, MIN:0) == “would overflow”)
run_out();
限流计数器
与抢购限流器类似,使用EXINCRBY命令的MAX选项可以实现限流计数器,伪代码如下:
if(EXINCRBY(rate_limitor, 1, MAX:1000) == “would overflow”)
traffic_control();
限流计数器的应用场景很多,例如并发限流、访问频率限制、密码修改次数限制等等。以并发限流为例,在请求的并发量突然超过系统的性能限制时,为了防止服务彻底崩溃引发更大的问题,采用限速器限制并发量,保证系统处理能力内的请求得到及时回应,是一种较合理的临时解决方案。使用TairStringEXINCRBY命令,您可以通过简单的代码设置一个并发限流器:
public boolean tryAcquire(Jedis jedis,String rateLimitor,int limiter){
try {
jedis.getClient().sendCommand(TairCommand.EXINCRBY,rateLimitor,"1","EX","1","MAX",String.valueOf(limiter)); // 设置限流器
jedis.getClient().getIntegerReply();
return true;
}catch (Exception e){
if(e.getMessage().contains("increment or decrement would overflow")){ // 检查返回结果中是否包含错误信息
return false;
}
throw e;
}
}
热点key问题的发现与解决
在Redis中,访问频率高的key称为热点key。热点key处理不当容易造成Redis进程阻塞,影响正常服务
产生原因
用户消费的数据远大于生产的数据(热卖商品、热点新闻、热点评论、明星直播)。
在日常工作生活中一些突发的的事件,例如:双十一期间某些热门商品的降价促销,当这其中的某一件商品被数万次点击浏览或者购买时,会形成一个较大的需求量,这种情况下就会造成热点问题。同理,被大量刊发、浏览的热点新闻、热点评论、明星直播等,这些典型的读多写少的场景也会产生热点问题。
请求分片集中,超过单Server的性能极限。
在服务端读数据进行访问时,往往会对数据进行分片切分,此过程中会在某一主机Server上对相应的Key进行访问,当访问超过Server极限时,就会导致热点Key问题的产生
流量集中,达到物理网卡上限。
请求过多,缓存分片服务被打垮。
DB击穿,引起业务雪崩。
如前文讲到的,当某一热点Key的请求在某一主机上超过该主机网卡上限时,由于流量的过度集中,会导致服务器中其它服务无法进行。如果热点过于集中,热点Key的缓存过多,超过目前的缓存容量时,就会导致缓存分片服务被打垮现象的产生。当缓存服务崩溃后,此时再有请求产生,会缓存到后台DB上,由于DB本身性能较弱,在面临大请求时很容易发生请求穿透现象,会进一步导致雪崩现象,严重影响设备的性能。
解决方案
通常的解决方案主要集中在对客户端和Server端进行相应的改造。
服务端缓存方案
首先Client会将请求发送至Server上,而Server又是一个多线程的服务,本地就具有一个基于Cache LRU策略的缓存空间。当Server本身就拥堵时,Server不会将请求进一步发送给DB而是直接返回,只有当Server本身畅通时才会将Client请求发送至DB,并且将该数据重新写入到缓存中。此时就完成了缓存的访问跟重建。
但该方案也存在以下问题:
缓存失效,多线程构建缓存问题
缓存丢失,缓存构建问题
脏读问题
使用Memcache、Redis方案
该方案通过在客户端单独部署缓存的方式来解决热点Key问题。使用过程中Client首先访问服务层,再对同一主机上的缓存层进行访问。该种解决方案具有就近访问、速度快、没有带宽限制的优点,但是同时也存在以下问题。
内存资源浪费
脏读问题
使用本地缓存方案
使用本地缓存则存在以下问题:
需要提前获知热点
缓存容量有限
不一致性时间增长
热点Key遗漏
读写分离方案解决热读
架构中各节点的作用如下:
SLB层做负载均衡
Proxy层做读写分离自动路由
Master负责写请求
ReadOnly节点负责读请求
Replica节点和Master节点做高可用
实际过程中Client将请求传到SLB,SLB又将其分发至多个Proxy内,通过Proxy对请求的识别,将其进行分类发送。例如,将同为Write的请求发送到Master模块内,而将Read的请求发送至ReadOnly模块。而模块中的只读节点可以进一步扩充,从而有效解决热点读的问题。读写分离同时具有可以灵活扩容读热点能力、可以存储大量热点Key、对客户端友好等优点。
热点数据解决方案
该方案通过主动发现热点并对其进行存储来解决热点Key的问题。首先Client也会访问SLB,并且通过SLB将各种请求分发至Proxy中,Proxy会按照基于路由的方式将请求转发至后端的Redis中。
在热点key的解决上是采用在服务端增加缓存的方式进行。具体来说就是在Proxy上增加本地缓存,本地缓存采用LRU算法来缓存热点数据,后端db节点增加热点数据计算模块来返回热点数据。
Proxy架构的主要有以下优点:
Proxy本地缓存热点,读能力可水平扩展
DB节点定时计算热点数据集合
DB反馈 Proxy 热点数据
对客户端完全透明,不需做任何兼容
热点key处理
热点数据的读取
在热点Key的处理上主要分为写入跟读取两种形式,在数据写入过程当SLB收到数据K1并将其通过某一个Proxy写入一个Redis,完成数据的写入。假若经过后端热点模块计算发现K1成为热点key后, Proxy会将该热点进行缓存,当下次客户端再进行访问K1时,可以不经Redis。最后由于proxy是可以水平扩充的,因此可以任意增强热点数据的访问能力。
热点数据的发现
对于db上热点数据的发现,首先会在一个周期内对Key进行请求统计,在达到请求量级后会对热点Key进行热点定位,并将所有的热点Key放入一个小的LRU链表内,在通过Proxy请求进行访问时,若Redis发现待访点是一个热点,就会进入一个反馈阶段,同时对该数据进行标记。
DB计算热点时,主要运用的方法和优势有:
基于统计阀值的热点统计
基于统计周期的热点统计
基于版本号实现的无需重置初值统计方法
DB 计算同时具有对性能影响极其微小、内存占用极其微小等优点
两种方案对比
通过上述对比分析可以看出,阿里云在解决热点Key上较传统方法相比都有较大的提高,无论是基于读写分离方案还是热点数据解决方案,在实际处理环境中都可以做灵活的水平能力扩充、都对客户端透明、都有一定的数据不一致性。此外读写分离模式可以存储更大量的热点数据,而基于Proxy的模式有成本上的优势。
读写分离
背景
云数据库Redis版不管主从版还是集群规格,replica作为备库不对外提供服务,只有在发生HA的时候,replica提升为master后才承担读写流量。这种架构读写请求都在master上完成,一致性较高,但性能受到master数量的限制。经常有用户数据较少,但因为流量或者并发太高而不得不升级到更大的集群规格。
为满足读多写少的业务场景,最大化节约用户成本,云数据库Redis版推出了读写分离规格,为用户提供透明、高可用、高性能、高灵活的读写分离服务。
架构
Redis集群模式有redis-proxy、master、replica、HA等几个角色。在读写分离实例中,新增read-only replica角色来承担读流量,replica作为热备不提供服务,架构上保持对现有集群规格的兼容性。redis-proxy按权重将读写请求转发到master或者某个read-only replica上;HA负责监控DB节点的健康状态,异常时发起主从切换或重搭read-only replica,并更新路由。
一般来说,根据master和read-only replica的数据同步方式,可以分为两种架构:星型复制和链式复制。
星型复制
星型复制就是将所有的read-only replica直接和master保持同步,每个read-only replica之间相互独立,任何一个节点异常不影响到其他节点,同时因为复制链比较短,read-only replica上的复制延迟比较小。
Redis是单进程单线程模型,主从之间的数据复制也在主线程中处理,read-only replica数量越多,数据同步对master的CPU消耗就越严重,集群的写入性能会随着read-only replica的增加而降低。此外,星型架构会让master的出口带宽随着read-only replica的增加而成倍增长。Master上较高的CPU和网络负载会抵消掉星型复制延迟较低的优势,因此,星型复制架构会带来比较严重的扩展问题,整个集群的性能会受限于master。
链式复制
链式复制将所有的read-only replica组织成一个复制链,如下图所示,master只需要将数据同步给replica和复制链上的第一个read-only replica。
链式复制解决了星型复制的扩展问题,理论上可以无限增加read-only replica的数量,随着节点的增加整个集群的性能也可以基本上呈线性增长。
链式复制的架构下,复制链越长,复制链末端的read-only replica和master之间的同步延迟就越大,考虑到读写分离主要使用在对一致性要求不高的场景下,这个缺点一般可以接受。但是如果复制链中的某个节点异常,会导致下游的所有节点数据都会大幅滞后。更加严重的是这可能带来全量同步,并且全量同步将一直传递到复制链的末端,这会对服务带来一定的影响。为了解决这个问题,读写分离的Redis都使用阿里云优化后的binlog复制版本,最大程度的降低全量同步的概率。
结合上述的讨论和比较,Redis读写分离选择链式复制的架构。
Redis读写分离优势
透明兼容
读写分离和普通集群规格一样,都使用了redis-proxy做请求转发,多分片令使用存在一定的限制,但从主从升级单分片读写分离,或者从集群升级到多分片的读写分离集群可以做到完全兼容。
用户和redis-proxy建立连接,redis-proxy会识别出客户端连接发送过来的请求是读还是写,然后按照权重作负载均衡,将请求转发到后端不同的DB节点中,写请求转发给master,读操作转发给read-only replica(master默认也提供读,可以通过权重控制)。
用户只需要购买读写分离规格的实例,直接使用任何客户端即可直接使用,业务不用做任何修改就可以开始享受读写分离服务带来的巨大性能提升,接入成本几乎为0。
高可用
高可用模块(HA)监控所有DB节点的健康状态,为整个实例的可用性保驾护航。master宕机时自动切换到新主。如果某个read-only replica宕机,HA也能及时感知,然后重搭一个新的read-only replica,下线宕机节点。
除HA之外,redis-proxy也能实时感知每个read-only replica的状态。在某个read-only replica异常期间,redis-proxy会自动降低这个节点的权重,如果发现某个read-only replica连续失败超过一定次数以后,会暂时屏蔽异常节点,直到异常消失以后才会恢复其正常权重。
redis-proxy和HA一起做到尽量减少业务对后端异常的感知,提高服务可用性。
高性能
对于读多写少的业务场景,直接使用集群版本往往不是最合适的方案,现在读写分离提供了更多的选择,业务可以根据场景选择最适合的规格,充分利用每一个read-only replica的资源。
目前单shard对外售卖1 master + 1/3/5 read-only replica多种规格(如果有更大的需求可以提工单反馈),提供60万QPS和192 MB/s的服务能力,在完全兼容所有命令的情况下突破单机的资源限制。后续将去掉规格限制,让用户根据业务流量随时自由的增加或减少read-only replica数量。
规格 QPS 带宽
1 master 8-10万读写 10-48 MB
1 master + 1 read-only replica 10万写 + 10万读 20-64 MB
1 master + 3 read-only replica 10万写 + 30万读 40-128 MB
1 master + 5 read-only replica 10万写 + 50万读 60-192 MB
后续
Redis主从异步复制,从read-only replica中可能读到旧的数据,使用读写分离需要业务可以容忍一定程度的数据不一致,后续将会给客户更灵活的配置和更大的自由,例如配置可以容忍的最大延迟时间。
热点key重建优化
缓存+过期时间的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。但有两个问题:
热点key,并发量非常大
重建缓存不能在短时间完成(长时生成缓存)
互斥锁
永远不过期,但是重建期间会有不一致问题
少量数据存储,高速读写访问。通过数据全部in-momery 的方式来保证高速访问
Redis3.0以后开始支持集群,实现了半自动化的数据分片,不过需要smart-client的支持
Redis使用单线程的IO复用模型,自己封装了一个简单的AeEvent事件处理框架,主要实现了epoll、kqueue和select,对于单纯只有IO操作来说,单线程可以将速度优势发挥到最大,但是Redis也提供了一些简单的计算功能,比如排序、聚合等,对于这些操作,单线程模型实际会严重影响整体吞吐量,CPU计算过程中,整个IO调度都是被阻塞住的
Redis使用现场申请内存的方式来存储数据,并且很少使用free-list等方式来优化内存分配,会在一定程度上存在内存碎片,Redis跟据存储命令参数,会把带过期时间的数据单独存放在一起,并把它们称为临时数据,非临时数据是永远不会被剔除的,即便物理内存不够,导致swap也不会剔除任何非临时数据(但会尝试剔除部分临时数据),这点上Redis更适合作为存储而不是cache。
在一致性问题上,个人感觉redis没有memcached实现的好,Memcached提供了cas命令,可以保证多个并发访问操作同一份数据的一致性问题。 Redis没有提供cas 命令,并不能保证这点,不过Redis提供了事务的功能,可以保证一串命令的原子性,中间不会被任何操作打断。
Redis除key/value之外,还支持list,set,sorted set,hash等众多数据结构,提供了KEYS进行枚举操作,但不能在线上使用,如果需要枚举线上数据,Redis提供了工具可以直接扫描其dump文件,枚举出所有数据,Redis还同时提供了持久化和复制等功能
Java客户端Jedis.里面提供了丰富的接口、
从2.8开始,Slave会周期性(每秒一次)发起一个Ack确认复制流(replication stream)被处理进度
redis支持读写分离,而且使用简单,只需在配置文件中把redis读服务器和写服务器进行配置,多个服务器使用逗号分开如下:
增删集群节点后会自动的进行数据迁移。实现 Redis 集群在线重配置的核心就是将槽从一个节点移动到另一个节点的能力。因为一个哈希槽实际上就是一些键的集合, 所以 Redis 集群在重哈希(rehash)时真正要做的,就是将一些键从一个节点移动到另一个节点
电商秒杀系统
秒杀活动是绝大部分电商选择的低价促销、推广品牌的方式。不仅可以给平台带来用户量,还可以提高平台知名度。一个好的秒杀系统,可以提高平台系统的稳定性和公平性,获得更好的用户体验,提升平台的口碑,从而提升秒杀活动的最大价值。本文讨论Redis版缓存设计高并发的秒杀系统。
秒杀的特征
秒杀活动对稀缺或者特价的商品进行定时定量售卖,吸引成大量的消费者进行抢购,但又只有少部分消费者可以下单成功。因此,秒杀活动将在较短时间内产生比平时大数十倍,上百倍的页面访问流量和下单请求流量。
秒杀活动可以分为3个阶段:
秒杀前:用户不断刷新商品详情页,页面请求达到瞬时峰值。
秒杀开始:用户点击秒杀按钮,下单请求达到瞬时峰值。
秒杀后:一部分成功下单的用户不断刷新订单或者产生退单操作,大部分用户继续刷新商品详情页等待退单机会。
消费者提交订单,一般做法是利用数据库的行级锁,只有抢到锁的请求可以进行库存查询和下单操作。但是在高并发的情况下,数据库无法承担如此大的请求,往往会使整个服务blocked,在消费者看来就是服务器宕机。
秒杀系统的流量虽然很高,但是实际有效流量是十分有限的。利用系统的层次结构,在每个阶段提前校验,拦截无效流量,可以减少大量无效的流量涌入数据库。
利用浏览器缓存和CDN抗压静态页面流量
秒杀前,用户不断刷新商品详情页,造成大量的页面请求。所以,我们需要把秒杀商品详情页与普通的商品详情页分开。对于秒杀商品详情页尽量将能静态化的元素静态化处理,除了秒杀按钮需要服务端进行动态判断,其他的静态数据可以缓存在浏览器和CDN上。这样,秒杀前刷新页面导致的流量进入服务端的流量只有很小的一部分。
利用读写分离Redis缓存拦截流量
CDN是第一级流量拦截,第二级流量拦截我们使用支持读写分离的Redis。在这一阶段我们主要读取数据,读写分离Redis能支持高达60万以上qps,完全可以支持需求。
首先通过数据控制模块,提前将秒杀商品缓存到读写分离Redis,并设置秒杀开始标记如下:
“goodsId_count”: 100 //总数
“goodsId_start”: 0 //开始标记
“goodsId_access”: 0 //接受下单数
秒杀开始前,服务集群读取goodsId_Start为0,直接返回未开始。
数据控制模块将goodsId_start改为1,标志秒杀开始。
服务集群缓存开始标记位并开始接受请求,并记录到Redis中goodsId_access,商品剩余数量为(goodsId_count - goodsId_access)。
当接受下单数达到goodsId_count后,继续拦截所有请求,商品剩余数量为0。
可以看出,最后成功参与下单的请求只有少部分可以被接受。在高并发的情况下,允许稍微多的流量进入。因此可以控制接受下单数的比例。
利用主从版Redis缓存加速库存扣量
成功参与下单后,进入下层服务,开始进行订单信息校验,库存扣量。为了避免直接访问数据库,我们使用主从版Redis来进行库存扣量,主从版Redis提供10万级别的QPS。使用Redis来优化库存查询,提前拦截秒杀失败的请求,将大大提高系统的整体吞吐量。
通过数据控制模块提前将库存存入Redis,将每个秒杀商品在Redis中用一个hash结构表示。
“goodsId” : {
“Total”: 100
“Booked”: 100
}
扣量时,服务器通过请求Redis获取下单资格,通过以下lua脚本实现,由于Redis是单线程模型,lua可以保证多个命令的原子性。
local n = tonumber(ARGV[1])
if not n or n == 0 then
return 0
end
local vals = redis.call(“HMGET”, KEYS[1], “Total”, “Booked”);
local total = tonumber(vals[1])
local blocked = tonumber(vals[2])
if not total or not blocked then
return 0
end
if blocked + n <= total then
redis.call(“HINCRBY”, KEYS[1], “Booked”, n)
return n;
end
return 0
先使用SCRIPT LOAD将lua脚本提前缓存在Redis,然后调用EVALSHA调用脚本,比直接调用EVAL节省网络带宽:
redis 127.0.0.1:6379>SCRIPT LOAD “lua code”
“438dd755f3fe0d32771753eb57f075b18fed7716”
redis 127.0.0.1:6379>EVAL 438dd755f3fe0d32771753eb57f075b18fed7716 1 goodsId 1
秒杀服务通过判断Redis是否返回抢购个数n,即可知道此次请求是否扣量成功。
使用主从版Redis实现简单的消息队列异步下单入库
扣量完成后,需要进行订单入库。如果商品数量较少的时候,直接操作数据库即可。如果秒杀的商品是1万,甚至10万级别,那数据库锁冲突将带来很大的性能瓶颈。因此,利用消息队列组件,当秒杀服务将订单信息写入消息队列后,即可认为下单完成,避免直接操作数据库。
消息队列组件依然可以使用Redis实现,在R2中用list数据结构表示。
orderList {
[0] = {订单内容}
[1] = {订单内容}
[2] = {订单内容}
…
}
将订单内容写入Redis:
LPUSH orderList {订单内容}
异步下单模块从Redis中顺序获取订单信息,并将订单写入数据库。
BRPOP orderList 0
通过使用Redis作为消息队列,异步处理订单入库,有效的提高了用户的下单完成速度。
数据控制模块管理秒杀数据同步
最开始,利用读写分离Redis进行流量限制,只让部分流量进入下单。对于下单检验失败和退单等情况,需要让更多的流量进来。因此,数据控制模块需要定时将数据库中的数据进行一定的计算,同步到主从版Redis,同时再同步到读写分离的Redis,让更多的流量进来。
消息队列
一般使用list结构作为队列,rpush生产消息,lpop消费消息。当lpop没有消息的时候,要适当sleep一会再重试。
list还有个指令叫blpop,在没有消息的时候,它会阻塞住直到消息到来。
使用pub/sub主题订阅者模式,可以实现 1:N 的消息队列。
在消费者下线的情况下,生产的消息会丢失,得使用专业的消息队列如RocketMQ等。
延时队列
使用sortedset,拿时间戳作为score,消息内容作为key调用zadd来生产消息,消费者用zrangebyscore指令获取N秒之前的数据轮询进行处理。
Redis Sentinal
Redis Sentinal着眼于高可用,在master宕机时会自动将slave提升为master,继续提供服务。
Redis Sentinel 用于管理多个 Redis 服务器,它有三个功能:
监控(Monitoring) - Sentinel 会不断地检查你的主服务器和从服务器是否运作正常。
提醒(Notification) - 当被监控的某个 Redis 服务器出现问题时, Sentinel 可以通过 API 向管理员或者其他应用程序发送通知。
自动故障迁移(Automatic failover) - 当一个主服务器不能正常工作时, Sentinel 会开始一次自动故障迁移操作, 它会将失效主服务器的其中一个从服务器升级为新的主服务器, 并让失效主服务器的其他从服务器改为复制新的主服务器; 当客户端试图连接失效的主服务器时, 集群也会向客户端返回新主服务器的地址, 使得集群可以使用新主服务器代替失效服务器。
Redis 集群中应该有奇数个节点,所以至少有三个节点。
哨兵监控集群中的主服务器出现故障时,需要根据 quorum 选举出一个哨兵来执行故障转移。选举需要 majority,即大多数哨兵是运行的(2 个哨兵的 majority=2,3 个哨兵的 majority=2,5 个哨兵的 majority=3,4 个哨兵的 majority=2)。
假设集群仅仅部署 2 个节点
±—+ ±—+
| M1 |---------| R1 |
| S1 | | S2 |
±—+ ±—+
如果 M1 和 S1 所在服务器宕机,则哨兵只有 1 个,无法满足 majority 来进行选举,就不能执行故障转移。
Redis Cluster
Redis Cluster着眼于扩展性,在单个redis内存不足时,使用Cluster进行分片存储。
Redis Cluster
Redis Cluster 去中心化,每个节点保存数据和整个集群状态,每个节点都和其他所有节点连接。
Redis Cluster 节点分配
Redis Cluster 特点:
所有的 redis 节点彼此互联(PING-PONG 机制),内部使用二进制协议优化传输速度和带宽。
节点的 fail 是通过集群中超过半数的节点检测失效时才生效。
客户端与 redis 节点直连,不需要中间 proxy 层。客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可。
redis-cluster 把所有的物理节点映射到[0-16383] 哈希槽 (hash slot)上(不一定是平均分配),cluster 负责维护 node、slot、value。
Redis 集群预分好 16384 个桶,当需要在 Redis 集群中放置一个 key-value 时,根据 CRC16(key) mod 16384 的值,决定将一个 key 放到哪个桶中。
Redis Cluster 主从模式
Redis Cluster 为了保证数据的高可用性,加入了主从模式。
一个主节点对应一个或多个从节点,主节点提供数据存取,从节点则是从主节点拉取数据备份。当这个主节点挂掉后,就会有这个从节点选取一个来充当主节点,从而保证集群不会挂掉。所以,在集群建立的时候,一定要为每个主节点都添加了从节点。
FAQ
1亿个key 使用keys命令是否会影响线上服务
Redis 是单线程处理,在线上 KEY 数量较多时,操作效率极低【时间复杂度为 O(N)】,该命令一旦执行会严重阻塞线上其它命令的正常请求,而且在高 QPS 情况下会直接造成 Redis 服务崩溃!如果有类似需求,请使用 scan 命令代替!
这个时候可以使用scan指令,scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys指令长.
Jedis是Redis的Java实现的客户端,其API提供了比较全面的Redis命令的支持。
Redisson实现了分布式和可扩展的Java数据结构,和Jedis相比,功能较为简单,不支持字符串操作,不支持排序、事务、管道、分区等Redis特性。
Redisson的宗旨是促进使用者对Redis的关注分离,从而让使用者能够将精力更集中地放在处理业务逻辑上。
Redis 的瓶颈最有可能是机器内存或者网络带宽。
既然单线程容易实现,而且 cpu 又不会成为瓶颈,那就顺理成章地采用单线程的方案了。
普通笔记本轻松处理每秒几十万的请求。
而且单线程并不代表就慢 nginx 和 nodejs 也都是高性能单线程的代表。
整个数据库统统加载在内存当中进行操作,定期通过异步操作把数据库数据flush到硬盘
Redis的性能非常出色,每秒可以处理超过 10万次读写操作
单个value的最大限制是1GB,不像 memcached只能保存1MB的数据,因此Redis可以用来实现很多有用的功能,比方说用他的List来做FIFO双向链表,实现一个轻量级的高性 能消息队列服务,用他的Set可以做高性能的tag系统等等。另外Redis也可以对存入的Key-Value设置expire时间,因此也可以被当作一 个功能加强版的memcached来用
set/get/decr/incr/mget
String在redis内部存储默认就是一个字符串,被redisObject所引用,当遇到incr、decr等操作时会转成数值型进行计算,此时redisObject的encoding字段为int
如何使用过Redis做异步队列?
一般使用list结构作为队列,rpush生产消息,lpop消费消息。当lpop没有消息的时候,要适当sleep一会再重试。
如果不用sleep,list还有个指令叫blpop,在没有消息的时候,它会阻塞住直到消息到来。
如果想要生产一次消费多次,可以使用pub/sub主题订阅者模式,可以实现1:N的消息队列,但在消费者下线后,生产的消息会丢失,想要持久化的话,需要使用消息队列如rabbitmq等。
redis如何实现延时队列?
使用sortedset,拿时间戳作为score,消息内容作为key调用zadd来生产消息,消费者用zrangebyscore指令获取N秒之前的数据轮询进行处理。
如果有大量的key需要设置同一时间过期,需要注意什么?
如果大量的key过期时间设置的过于集中,到过期的那个时间点,redis可能会出现短暂的卡顿现象。一般需要在过期时间上加一个随机值,使得过期时间分散一些。
Redis单点吞吐量
单点TPS达到8万/秒,QPS达到10万/秒,补充下TPS和QPS的概念
QPS: 应用系统每秒钟最大能接受的用户访问量。每秒钟处理完请求的次数,注意这里是处理完,具体是指发出请求到服务器处理完成功返回结果。可以理解在server中有个counter,每处理一个请求加1,1秒后counter=QPS。
TPS:每秒钟最大能处理的请求数。每秒钟处理完的事务次数,一个应用系统1s能完成多少事务处理,一个事务在分布式处理中,可能会对应多个请求,对于衡量单个接口服务的处理能力,用QPS比较合理。
Redis哈希槽
Redis集群没有使用一致性hash,而是引入了哈希槽的概念,当需要在 Redis 集群中放置一个 key-value 时,根据 CRC16(key) mod 16384的值,决定将一个key放到哪个桶中。
Redis集群最大节点个数是多少?
Redis集群预分好16384个桶(哈希槽)
Redis事务是什么?
Redis事务可以一次执行多个命令,有以下特点:
批量操作在发送 EXEC 命令前被放入队列缓存。
收到 EXEC 命令后进入事务执行,事务中任意命令执行失败,其余的命令依然被执行。
在事务执行过程,其他客户端提交的命令请求不会插入到事务执行命令序列中。
事务可以理解为一个打包的批量执行脚本,但批量指令并非原子化的操作,中间某条指令的失败不会导致前面已做指令的回滚,也不会造成后续的指令不做。
如何保证数据库与redis缓存一致
在写库前后都进行redis.del(key)操作,并且设定合理的超时时间。这样最差的情况是在超时时间内存在不一致,当然这种情况极其少见,可能的原因就是服务宕机。此种情况可以满足绝大多数需求。 当然这种策略要考虑redis和数据库主从同步的耗时,所以在第二次删除前最好休眠一定时间,比如500毫秒,这样毫无疑问又增加了写请求的耗时
缓存穿透
嗯,了解,先说下缓存穿透吧,缓存穿透是指缓存和数据库中都没有的数据,而用户(黑客)不断发起请求,举个栗子:我们数据库的id都是从1自增的,如果发起id=-1的数据或者id特别大不存在的数据,这样的不断攻击导致数据库压力很大,严重会击垮数据库。
我又接着说:至于缓存击穿嘛,这个跟缓存雪崩有点像,但是又有一点不一样,缓存雪崩是因为大面积的缓存失效,打崩了DB,而缓存击穿不同的是缓存击穿是指一个Key非常热点,在不停地扛着大量的请求,大并发集中对这一个点进行访问,当这个Key在失效的瞬间,持续的大并发直接落到了数据库上,就在这个Key的点上击穿了缓存。
面试官露出欣慰的眼光:那他们分别怎么解决?
我:缓存穿透我会在接口层增加校验,比如用户鉴权,参数做校验,不合法的校验直接return,比如id做基础校验,id<=0直接拦截。
那你还有别的方法吗?
我:我记得Redis里还有一个高级用法布隆过滤器(Bloom Filter)这个也能很好的预防缓存穿透的发生,他的原理也很简单,就是利用高效的数据结构和算法快速判断出你这个Key是否在数据库中存在,不存在你return就好了,存在你就去查DB刷新KV再return。缓存击穿的话,设置热点数据永不过期,或者加上互斥锁就搞定了。作为暖男,代码给你准备好了,拿走不谢。
public static String getData(String key) throws InterruptedException {
//从Redis查询数据
String result = getDataByKV(key);
//参数校验
if (StringUtils.isBlank(result)) {
try {
//获得锁
if (reenLock.tryLock()) {
//去数据库查询
result = getDataByDB(key);
//校验
if (StringUtils.isNotBlank(result)) {
//插进缓存
setDataToKV(key, result);
}
} else {
//睡一会再拿
Thread.sleep(100L);
result = getData(key);
}
} finally {
//释放锁
reenLock.unlock();
}
}
return result;
}
缓存穿透是指查询一个根本不存在的数据,缓存和数据源都不会命中。出于容错的考虑,如果从数据层查不到数据
则不写入缓存,即数据源返回值为 null 时,不缓存 null。缓存穿透问题可能会使后端数据源负载加大,由于很多后端数据源不具备高并发性,甚至可能造成后端数据源宕掉
解决方式
\1. 如果查询数据库也为空,直接设置一个默认值存放到缓存,这样第二次到缓冲中获取就有值了,而不会继续访
问数据库,这种办法最简单粗暴。比如,”key” , “&&”。
在返回这个&&值的时候,我们的应用就可以认为这是不存在的key,那我们的应用就可以决定是否继续等待继续访
问,还是放弃掉这次操作。如果继续等待访问,过一个时间轮询点后,再次请求这个key,如果取到的值不再是
&&,则可以认为这时候key有值了,从而避免了透传到数据库,从而把大量的类似请求挡在了缓存之中。
\2. 根据缓存数据Key的设计规则,将不符合规则的key进行过滤
采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的BitSet中,不存在的数据将会被拦截掉,从而避免了
对底层存储系统的查询压力
是指查询一个根本不存在的数据, 缓存层和存储层都不会命中。这会造成存储层压力变大。
设置比较短的过期时间,让其自动剔除 数据不一致:
存储层添加了数据,但是缓存空对象还没过期, 方案是可以使用消息队列
数据命中不高、 数据相对固定、 实时性低(通常是数据集较大) 的应用场景, 代码维护较为复杂,但是缓存空间占用少
如果缓存集中在一段时间内失效,发生大量的缓存穿透,所有的查询都落在数据库上,就会造成缓存雪崩。
解决方案:
尽量让失效的时间点不分布在同一个时间点
缓存穿透是指查询一个一定不存在的数据。由于缓存命不中时会去查询数据库,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿透。
缓存穿透:指查询一个一定不存在的数据,由于缓存是不命中时需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿透。
解决方案:最简单粗暴的方法如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们就把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。
①是将空对象也缓存起来,并给它设置一个很短的过期时间,最长不超过5分钟
② 采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力
缓存雪崩
缓存雪崩
目前电商首页以及热点数据都会去做缓存,一般缓存都是定时任务去刷新,或者查不到之后去更新缓存的,定时任务刷新就有一个问题。举个栗子:如果首页所有Key的失效时间都是12小时,中午12点刷新的,我零点有个大促活动大量用户涌入,假设每秒6000个请求,本来缓存可以抗住每秒5000个请求,但是缓存中所有Key都失效了。此时6000个/秒的请求全部落在了数据库上,数据库必然扛不住,真实情况可能DBA都没反应过来直接挂了,此时,如果没什么特别的方案来处理,DBA很着急,重启数据库,但是数据库立马又被新流量给打死了。这就是我理解的缓存雪崩。
我心想:同一时间大面积失效,瞬间Redis跟没有一样,那这个数量级别的请求直接打到数据库几乎是灾难性的,你想想如果挂的是一个用户服务的库,那其他依赖他的库所有接口几乎都会报错,如果没做熔断等策略基本上就是瞬间挂一片的节奏,你怎么重启用户都会把你打挂,等你重启好的时候,用户早睡觉去了,临睡之前,骂骂咧咧“什么垃圾产品”。
处理缓存雪崩简单,在批量往Redis存数据的时候,把每个Key的失效时间都加个随机值就好了,这样可以保证数据不会再同一时间大面积失效。
setRedis(key, value, time+Math.random()*10000);
如果Redis是集群部署,将热点数据均匀分布在不同的Redis库中也能避免全部失效。或者设置热点数据永不过期,有更新操作就更新缓存就好了(比如运维更新了首页商品,那你刷下缓存就好了,不要设置过期时间),电商首页的数据也可以用这个操作,保险
缓存雪崩是指设置缓存时采用了相同的过期时间,导致缓存在某一个时刻同时失效,或者缓存服务器宕机宕机导致
缓存全面失效,请求全部转发到了DB层面,DB由于瞬间压力增大而导致崩溃。缓存失效导致的雪崩效应对底层系
统的冲击是很大的。
解决方式
\1. 对缓存的访问,如果发现从缓存中取不到值,那么通过加锁或者队列的方式保证缓存的单进程操作,从而避免
失效时并发请求全部落到底层的存储系统上;但是这种方式会带来性能上的损耗
\2. 将缓存失效的时间分散,降低每一个缓存过期时间的重复率
\3. 如果是因为缓存服务器故障导致的问题,一方面需要保证缓存服务器的高可用、另一方面,应用程序中可以采
用多级缓存
由于缓存层承载着大量请求,有效地保存了存储层,但是如果缓存层由于某些原因不能提供服务,于是所有的请求都会到达存储层,存储层的调用会暴增。
保证缓存层服务高可用性,redis提供了Redis Sentinel和Redis Cluster
依赖隔离组件为后端限流并降级,降级机制,如Hystrix
提前演练,项目上线前,演练缓存层宕掉后,应用及后端的负载情况以及可能出现的问题。
1、概念
缓存雪崩是指,缓存层出现了错误,不能正常工作了。于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会挂掉的情况。
2、解决方案
(1)redis高可用
这个思想的含义是,既然redis有可能挂掉,那我多增设几台redis,这样一台挂掉之后其他的还可以继续工作,其实就是搭建的集群。
(2)限流降级
这个解决方案的思想是,在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。
(3)数据预热
数据加热的含义就是在正式部署之前,我先把可能的数据先预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中。在即将发生大并发访问前手动触发加载缓存不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。
与缓存击穿的区别在于这里针对很多key缓存,前者则是某一个key。
缓存正常从Redis中获取,示意图如下:
redis1.md
缓存失效瞬间示意图如下:
redis2.md
缓存失效时的雪崩效应对底层系统的冲击非常可怕!大多数系统设计者考虑用加锁或者队列的方式保证来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上。还有一个简单方案就时讲缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
加锁排队,伪代码如下:
//伪代码
public object GetProductListNew() {
int cacheTime = 30;
String cacheKey = "product_list";
String lockKey = cacheKey;
String cacheValue = CacheHelper.get(cacheKey);
if (cacheValue != null) {
return cacheValue;
} else {
synchronized(lockKey) {
cacheValue = CacheHelper.get(cacheKey);
if (cacheValue != null) {
return cacheValue;
} else {
//这里一般是sql查询数据
cacheValue = GetProductListFromDB();
CacheHelper.Add(cacheKey, cacheValue, cacheTime);
}
}
return cacheValue;
}
}
加锁排队只是为了减轻数据库的压力,并没有提高系统吞吐量。假设在高并发下,缓存重建期间key是锁着的,这是过来1000个请求999个都在阻塞的。同样会导致用户等待超时,这是个治标不治本的方法!
注意:加锁排队的解决方式分布式环境的并发问题,有可能还要解决分布式锁的问题;线程还会被阻塞,用户体验很差!因此,在真正的高并发场景下很少使用!
随机值伪代码:
//伪代码
public object GetProductListNew() {
int cacheTime = 30;
String cacheKey = "product_list";
//缓存标记
String cacheSign = cacheKey + "_sign";
String sign = CacheHelper.Get(cacheSign);
//获取缓存值
String cacheValue = CacheHelper.Get(cacheKey);
if (sign != null) {
return cacheValue; //未过期,直接返回
} else {
CacheHelper.Add(cacheSign, "1", cacheTime);
ThreadPool.QueueUserWorkItem((arg) -> {
//这里一般是 sql查询数据
cacheValue = GetProductListFromDB();
//日期设缓存时间的2倍,用于脏读
CacheHelper.Add(cacheKey, cacheValue, cacheTime * 2);
});
return cacheValue;
}
}
解释说明:
缓存标记:记录缓存数据是否过期,如果过期会触发通知另外的线程在后台去更新实际key的缓存;
缓存数据:它的过期时间比缓存标记的时间延长1倍,例:标记缓存时间30分钟,数据缓存设置为60分钟。这样,当缓存标记key过期后,实际缓存还能把旧数据返回给调用端,直到另外的线程在后台更新完成后,才会返回新缓存。
关于缓存崩溃的解决方法,这里提出了三种方案:使用锁或队列、设置过期标志更新缓存、为key设置不同的缓存失效时间,还有一种被称为“二级缓存”的解决方法。
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是, 缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方案:
缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
设置热点数据永远不过期。
缓存击穿
缓存击穿,是指一个key非常热点,在不停的扛着大并发,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。
解决方案:
可以设置key永不过期
缓存一致性问题
缓存和数据库数据一致性问题:分布式环境下非常容易出现缓存和数据库间数据一致性问题,针对这一点,如果项目对缓存的要求是强一致性的,那么就不要使用缓存。我们只能采取合适的策略来降低缓存和数据库间数据不一致的概率,而无法保证两者间的强一致性。合适的策略包括合适的缓存更新策略,更新数据库后及时更新缓存、缓存失败时增加重试机制。
一、缓存双淘汰法
先淘汰缓存
再写数据库
将淘汰消息发送到消息总线esb,然后立即将其发送回。写入请求处理时间几乎没有增加,并且此方法淘汰了缓存两次。因此,被称为“缓存双淘汰法“,并且在消息总线的下游,有一个异步淘汰缓存的消费者,在拿到淘汰消息在1s后淘汰缓存,这样,即使在1秒内有脏数据入缓存,也能够被淘汰掉。
二、异步淘汰缓存
以上步骤都是在业务线中执行的,增加了一个线下的读取binlog异步淘汰缓存模块,在读取binlog总的数据,然后进行异步淘汰。
1.思路:
MySQL binlog增量发布订阅消耗+消息队列+增量数据更新到redis
1)读取请求转到Redis:热数据基本上在Redis
2)写入请求转到MySQL:增加删除和 修改MySQL
3)更新Redis数据:MySQ数据操作binlog更新为Redis
2.Redis更新
1)数据操作主要有两块:
一个是全量(将全部数据一次写入到redis)
一个是增量(实时更新)
这里说的是增量,指mysql的更新,插入和删除,以更改数据
这样,一旦MySQL中发生新的写入,更新,删除和其他操作,就可以将与Binlog相关的消息推送到Redis,然后Redis会根据binlog中的记录来更新Redis。 无需处理业务线中的缓存内容。
合理设置缓存的过期时间。
新增、更改、删除数据库操作时同步更新 Redis,可以使用事物机制来保证数据的一致性。
\1. 如果更新缓存的代价很小,那么可以先更新缓存,这个代价很小的意思是我不需要很复杂的计算去获得最新的
余额数字。
\2. 如果是更新缓存的代价很大,意味着需要通过多个接口调用和数据查询才能获得最新的结果,那么可以先淘汰
缓存。淘汰缓存以后后续的请求如果在缓存中找不到,自然去数据库中检索。
当客户端发起事务类型请求时,假设我们以让缓存失效作为缓存的的处理方式,那么又会存在两个情况\1. 先更新数据库再让缓存失效
\2. 先让缓存失效,再更新数据库前面我们讲过,更新数据库和更新缓存这两个操作,是无法保证原子性的,所以我们需要根据当前业务的场景的容
忍性来选择。也就是如果出现不一致的情况下,哪一种更新方式对业务的影响最小,就先执行影响最小的方案
如果insetdb ,insetredis
如果update ,将redis中该key删除。—懒加载。
如果update ,直接修改redis。—增量同步。
如果delete ,将redis中该key删除。—增量同步
如果数据库数据发生变化,如何同步给Redis
如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加 上过期时间,每隔一段时间触发读的主动更新即可
如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式(比较优秀)。
缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心 脏数据,允许临时脏数据可忽略);
直接清除Redis缓存;(适合于小项目)基于接口形式实现同步
基于MQ形式异步同步 (适合于中小项目)基于接口形式实现同步
基于canal+mq异步同步 (推荐)基于源头binlog二进制文件实现同步
- Canal服务器端伪装成一个mysql从节点,订阅mysql主节点的binlog二进制文件
- Canal服务器端收到binlog文件,就会转换成json的格式发送给Canal客户端。
- Canal客户端会将该数据同步给nosql缓存 redis
1. 先请求查询redis缓存,如果redis缓存没有数据则查询mysql数据库,如果mysql数据库存在数据,则将该数据缓存到Redis中。
2. 先请求查询Redis缓存,如果redis缓存存在数据的话,则不会查询mysql数据
Canal接收到mysqlbinlog文件,将该消息缓存到kafka中,再开启多个消费者。
异步地获取消息同步到Redis中,能够提高同步的效率。
方案二
我们加一个缓存,将近期被修改的数据进行标记锁定。读的时候,标记锁定的数据强行走DB,没锁定的数据,先走缓存
流程:
我们把修改的数据通过Cache_0标记“正在被修改”,如果标记成功,则继续往下走;那如果标记失败,则要放弃这次修改。
何为标记锁定呢?比如你可以设定一个有效期为10S的key,Key存在即为锁定。一般来说10S对于后面的同步操作来说基本是够了~
如果说,还想更严谨一点,怕DB主从延迟太久、MQ延迟太久,或Databus监听的从库挂机之类的情况,我们可以考虑增加一个监控定时任务。比如我们增加一个时间间隔2S的worker的去对比以下两个数据:
时间1:最后修改数据库的时间VS
时间2:最后由更新引起的’MQ刷新缓存对应数据的实际更新数据库’的时间
数据1:可由步骤1.1获得,并存储数据2:需要由binlog中解析获得,需要透传到MQ,这样后面就能存储了这里提一下:如果多库的情况的话,存储这两个key需要与库一一对应
如果 时间1 VS 时间2 相差超过5S,那我们就自动把相应的缓存分片读降级。
读流程:
先读Cache_0,看看要读的数据是否被标记,如果被标记,则直接读主库;如果没有被标记,去读缓存,缓存没有就读DB,具体流程看流程图。
- 容灾完善
写流程容灾分析
写1.1 标记失败:没关系,放弃整个更新操作
写1.3 DEL缓存失败:没关系,后面会覆盖
写1.5 写MQ失败:没关系,Databus或Canal都会重试
消费MQ的:1.6 || 1.7 失败:没关系,重新消费即可
读流程容灾分析
读2.1 读Cache_0失败:没关系,直接读主库
读2.3 异步写MQ失败:没关系,缓存为空,是OK的,下次还读库就好了
2. 无并发问题
这个方案让“读库 + 刷缓存”的操作串行化,这就不存在老数据覆盖新数据的并发问题了
缺点剖析
- 增加Cache_0强依赖
这个其实有点没办法,你要强一致性,必然要牺牲一些的。但是呢,你这个可以吧Cache_0设计成多机器多分片,这样的话,即使部分分片挂了,也只有小部分流量透过Cache直接打到DB上,这是完全是可接受的 - 复杂度是比较高的
涉及到Databus、MQ、定时任务等等组件,实现起来复杂度还是有的
数据结构
对象头
typedef struct redisObject {
// 类型 4bits,即上面[String,List,Hash,Set,Zset]中的一个
unsigned type:4;
// 编码方式 4bits,encoding表示对象底层所使用的编码。
unsigned encoding:4;
// LRU 时间(相对于 server.lruclock) 24bits
unsigned lru:22;
// 引用计数 Redis里面的数据可以通过引用计数进行共享 32bits
int refcount;
// 指向对象的值 64-bit
void *ptr;
} robj;// 16bytes
一个RedisObject占用的字节数:4+4+24+32+64=128位/8=16字节。
string
底层int/embstr/raw
sds(simple dynamic string):简单动态字符串。SDS只是字符串类型中存储字符串内容的结构,
Redis中的字符串分为两种存储方式,分别是embstr和raw。
sds中包含了free(当前可用空间大小),len(当前存储字符串长度),buf[] (存储的字符串内容),来看下
SDS的源码:
struct sdshdr{
//记录buf数组中已使用字节的数量
//等于 SDS 保存字符串的长度 4byte
int len;
//记录 buf 数组中未使用字节的数量 4byte
int free;
//字节数组,用于保存字符串 字节\0结尾的字符串占用了1byte
char buf[];
}
包含了len、free、buf[]三个属性。那么他占用字节最少是:4+4+1=9字节。(仅限redis3.2版本
之前。Redis3.2版本之后的sds结构发生了变化。)
字符串长度如果小于39的话,则采取embstr存储,否则采取raw类型存储。
为啥是39?原因:对象头占16字节,空的sdshdr占用9字节,也就是一个数据至少占用16+9=25
字节
其次操作系统使用jmalloc和tmalloc进行内存的分配,而内存分配的单位都是2的N次方,所以是
2,4,8,16,32,64等字节,但是redis如果采取32的话,那么32-25=7,也太他妈少了,所以Redis采
取的是64字节,所以:64-25=39
比如你 set abc abcdefg ,简单的一个set会创建出两个sds,一个存key:abc,一个存value:
abcdefg。
带着问题看答案:C语言中也有字符串类型,为啥她不用C的,反正他都是C语言写的,为啥要造个轮子
sds?
C语言要想获取字符串长度必须遍历整个字符串的每一个字符,然后自增做累加,时间复杂度为O(n);
sds直接维护了一个len变量,时间复杂度为O(1)。
当我们对一个字符串类型进行追加的时候,可能会发生两种情况:
当前剩余空间(free)足够容纳追加内容时,我们就不需要再去分配内存空间,这样可以减少内存分配次数。
当前剩余空间不足以容纳追加内容,我们需要重新为其申请内存空间。
比如下面的sds的方式,free还有三个空余空间呢,你插入的是hi两个字符,所以足够,不需要调用函数
重新分配,提升效率。
而C语言字符串在进行字符串的扩充和收缩的时候,都会面临着内存空间的重新分配问题。如果忘记分
配或者分配大小不合理还会造成数据污染问题。
那么sds的free值哪来的呢?也就是字符串扩容策略
当给sds的值追加一个字符串,而当前的剩余空间不够时,就会触发sds的扩容机制。扩容采用了空
间预分配的优化策略,即分配空间的时候:如果sds 值大小< 1M ,则增加一倍; 反之如果>1M , 则
当前空间加1M作为新的空间。
当sds的字符串缩短了,sds的buf内会多出来一些空间,这个空间并不会马上被回收,而是暂时留
着以防再用的时候进行多余的内存分配。这个是惰性空间释放的策略
惰性释放空间
当我们截断字符串时,Redis会把截断部分置空,只保留剩余部分,且不立即释放截断部分的内存空间,
这样做的好处就是当下次再对这个字符串追加内容的时候,如果当前剩余空间足以容纳追加内容时,就
不需要再去重新申请空间,避免了频繁的内存申请。暂时用不上的空间可以被Redis定时删除或者惰性删
除。
防止缓冲区溢出
其实和减少内存分配是成套的,都是因为sds预先检查内存自动分配来做到防止缓冲区溢出的。比如:
程序中有两个在内存中紧邻着的 字符串 s1 和 s2,其中s1 保存了字符串“redis”,二s2 则保存了字符串
“MongoDb”:
如果我们现在将s1 的内容修改为redis cluster,但是又忘了重新为s1 分配足够的空间,这时候就会出
现以下问题:
我们可以看到,原本s2 中的内容已经被S1的内容给占领了,s2 现在为 cluster,而不是“Mongodb”。造
成了缓冲区溢出,也是数据污染。
Redis中SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:
当我们需要对一个SDS 进行修改的时候,redis 会在执行拼接操作之前,预先检查给定SDS 空间是否足
够,如果不够,会先拓展SDS 的空间,然后再执行拼接操作
二进制安全
在C语言中通过判断当前字符是否为’\0’来确定字符串是否结束,而在sds结构中,只要遍历长度没有达到
len,即使遇到’\0’,也不会认为字符串结束。比如下面内存,C语言的字符串类型会丢失g123这四个字
符,因为他遇到’\0’就结束了,而sds不会存在此问题。
SDS
获取字符串长度的复杂度为O(1)
API 是安全的,不会造成缓冲区溢出
修改字符串长度N次最多执行N次内存重分配
可以保存二进制数据和文本文数据,二进制安
全
int
如果一个字符串内容可转为 long,那么该字符串会被转化为 long 类型,redisObject的对象 ptr 指向该
long,并将 encoding 设置为 int,这样就不需要重新开辟空间,算是长整形的一个优化。
embstr/raw
上面的SDS只是字符串类型中存储字符串内容的结构,Redis中的字符串分为两种存储方式,分别是
embstr和raw,当字符串长度特别短(redis3.2之前是39字节,redis3.2之后是44字节)的时候,Redis
使用embstr来存储字符串,而当字符串长度超过39(redis3.2之前)的时候,就需要用raw来存储,下
面是他们的字符串完整结构的示意图:
embstr的存储方式是将RedisObject对象头和SDS结构放在内存中连续的空间位置,也就是使用
malloc方法一次分配,而raw需要两次malloc,分别分配对象头和SDS的空间。释放空间也一样,
embstr释放一次,raw释放两次,所以embstr是一种优化,但是为什么是39字节才采取embstr呢?
39哪来的?
这个问题在上面sds里已经说过了。
原因:对象头占16字节,空的sdshdr占用9字节,也就是一个数据至少占用16+9=25字节。
其次操作系统使用jmalloc和tmalloc进行内存的分配,而内存分配的单位都是2的N次方,所以是
2,4,8,16,32,64等字节,但是redis如果采取32的话,那么32-25=7,也太他妈少了,所以Redis采取的是
64字节,所以:64-25=39。
(仅限redis3.2版本之前。Redis3.2版本之后的sds结构发生了变化【最小的是sdshdr5,空的话占用3字
节+1个空白=4字节,16+4=20;64-20=44】。)
- redis的string底层数据结构使用的是sds,但是sds有两种存储方式,一种是embstr,一种是raw。
- embstr的优势在于和对象头一起分配到连续空间,只需要调用函数malloc一次就行。raw需要两次,
一次是对象头,一次是sds。释放也一样,embstr释放一次,raw释放两次。 - 字符串内容可转为 long,采用 int 类型,否则长度<39(3.2版本前是39,3.2版本后分界线是44) 用
embstr,其他用 raw。 - SDS 是Redis自己构建的一种简单动态字符串的抽象类型,并将 SDS 作为 Redis 的默认字符串表示。
- SDS 与 C 语言字符串结构相比,具有四大优势
字符串类型是redis中最基本的数据类型,它能存储任何形式的字符串,包括二进制数据。你可以用它存储用户的
邮箱、json化的对象甚至是图片。一个字符类型键允许存储的最大容量是512M
在Redis内部,String类型通过 int、SDS(simple dynamic string)作为结构存储,int用来存放整型数据,sds存放字
节/字符串和浮点型数据。在C的标准字符串结构下进行了封装,用来提升基本操作的性能,同时也充分利用已有的
C的标准库,简化实现逻辑。我们可以在redis的源码中【sds.h】中看到sds的结构如下;
typedef char *sds;
redis3.2分支引入了五种sdshdr类型,目的是为了满足不同长度字符串可以使用不同大小的Header,从而节省内
存,每次在创建一个sds时根据sds的实际长度判断应该选择什么类型的sdshdr,不同类型的sdshdr占用的内存空
间不同。这样细分一下可以省去很多不必要的内存开销,下面是3.2的sdshdr定义
`struct __attribute__ ((__packed__)) sdshdr8 { 8表示字符串最大长度是2^8-1 (长度为255)``
uint8_t len;//表示当前sds的长度(单位是字节)`` uint8_t alloc; //表示已为sds分配的内存大小(单
位是字节)`` unsigned char flags; //用一个字节表示当前sdshdr的类型,因为有sdshdr有五种类型,所
以至少需要3位来表示000:sdshdr5,001:sdshdr8,010:sdshdr16,011:sdshdr32,100:sdshdr64。高5位
用不到所以都为0。`` char buf[];//sds实际存放的位置``};`
List
3.0之前:ziplist/linkedlist;3.0之后:quicklist
ziplist是一个经过特殊编码的双向链表,它的设计目标就是为了提高存储效率。
zlbytes:ziplist的长度,32位无符号整数。
zltail:ziplist最后一个节点的偏移量,反向遍历ziplist或者pop尾部节点的时候用来提升性能。
zllen:ziplist的entry(节点)个数。
entry:节点,并不是一个数组,然后里面存的值。而是一个数据结构。下面说。
zlend:值为255,用于标记ziplist的结尾。
entry的布局
prevlengh:记录上一个节点的长度,为了方便反向遍历ziplist。
encoding:当前的编码规则,记录了节点的content属性所保存数据类型以及长度。
data:保存节点的值。可以是字符串或者数字,值的类型和长度由encoding决定。
如果前一节点的长度小于254字节,那么 previous_entry_ength 属性的长度为1字节,前一节点的长度就
保存在这一个字节里面。
如果前一个节点的长度大于等于254,那么 previous_entry_ength 属性的长度为5字节,其中属性的第一
字节会被设置为0xFE(十进制254),而之后的四个字节则用于保存前一节点的长度。用254 不用
255(11111111)作为分界是因为255是zlend的值,它用于判断ziplist是否到达尾部。
利用此原理即当前节点位置减去上一个节点的长度即得到上一个节点的起始位置,压缩列表可以从尾部
向头部遍历,这么做很有效地减少了内存的浪费
ziplist是为节省内存空间而生的。
ziplist是一个为Redis专门提供的底层数据结构之一,本身可以有序也可以无序。当作为list和hash
的底层实现时,节点之间没有顺序;当作为zset的底层实现时,节点之间会按照大小顺序排列。
ziplist的弊端也很明显了,对于较多的entry或者entry长度较大时,需要大量的连续内存,并且节
省的空间比例相对不在占优势,就可以考虑使用其他结构了。
linkedlist
就是双向链表,对首尾节点的定位很快,O(1)复杂度。在首位前后插入节点也很是O(1)。
ziplist和linkedlist怎么选择?
Redis的list类型什么时候会使用ziplist编码,什么时候又会使用linkedlist编码呢?
当列表对象可以同时满足下列两个条件时,列表对象采用ziplist编码,否则采用linkedlist编码。
(1)列表对象保存的所有字符串元素的长度都小于64字节;
(2)列表元素保存的元素数量小于512个;
上述两个参数可以更改配置进行自定义。
Redis3.0版本开始对list数据结构采取quicklist了,抛弃了之前的ziplist和linkedlist。
quicklist 是一个双向链表,并且是一个ziplist的双向链表,也就是说一个quicklist由多个quicklistNode
组成,每个quicklistNode指向一个ziplist,一个ziplist包含多个entry元素,每个entry元素就是我们
push的list的元素。ziplist本身也是一个能维持数据项先后顺序的列表,而且数据项保存在一个连续的内
存块中。意味着quicklist结合了ziplist和linkedlist的特点!更为优化了,还省去了ziplist和linkedlist之间
转换的步骤了。
linkedlist:
1.双端链表便于在表的两端进行push和pop操作,但是它的内存开销比较大;
2.双端链表每个节点上除了要保存数据之外,还要额外保存两个指针(pre/next);
3.双端链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片;
ziplist:
1.ziplist由于是一整块连续内存,所以存储效率很高;
2.ziplist不利于修改操作,每次数据变动都会引发一次内存的realloc;
3.当ziplist长度很长的时候,一次realloc可能会导致大批量的数据拷贝,进一步降低性能;
quicklist:
1.空间换时间,之前linkedlist需要两个指针,浪费空间,我现在不用linkedlist,我都采取ziplist,然后
上面封装一层quicklistnode,底层存储还是ziplist,只是空间上多了一层指针用于检索。
2.结合了双端链表和压缩列表的优点。
SET KEY_NAME VALUE
Redis SET 命令用于设置给定 key 的值。如果 key 已经存储值, SET 就覆写旧值,且无视类型
SETNX key value //解决分布式锁 方案之一
只有在 key 不存在时设置 key 的值。Setnx(SET if Not eXists) 命令在指定的 key 不存在时,为 key 设置指定的值
MSET key value [key value …]
同时设置一个或多个 key-value 对
取值语法:
GET KEY_NAME
Redis GET命令用于获取指定 key 的值。如果 key 不存在,返回 nil 。如果key 储存的值不是字符串类型,返回一个错误。
GETRANGE key start end
用于获取存储在指定 key 中字符串的子字符串。字符串的截取范围由 start 和 end 两个偏移量决定(包括 start 和 end 在内)
GETBIT key offset
对 key 所储存的字符串值,获取指定偏移量上的位(bit)
MGET key1 [key2…]
获取所有(一个或多个)给定 key 的值
GETSET语法: GETSET KEY_NAME VALUE
Getset 命令用于设置指定 key 的值,并返回 key 的旧值,当 key 不存在时,返回 nil
STRLEN key
返回 key 所储存的字符串值的长度
删除语法:
DEL KEY_Name
删除指定的KEY,如果存在,返回值数字类型。
自增/自减:
INCR KEY_Name
Incr 命令将 key 中储存的数字值增1。如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作
自增:INCRBY KEY_Name 增量值
Incrby 命令将 key 中储存的数字加上指定的增量值
自减:DECR KEY_NAME 或 DECYBY KEY_NAME 减值
decR 命令将 key 中储存的数字减1
字符串拼接:APPEND KEY_NAME VALUE
Append 命令用于为指定的 key 追加至未尾,如果不存在,为其赋值
1、String通常用于保存单个字符串或JSON字符串数据
2、因String是二进制安全的,所以你完全可以把一个图片文件的内容作为字符串来存储
3、计数器(常规key-value缓存应用。常规计数: 微博数, 粉丝数)
INCR等指令本身就具有原子操作的特性,所以我们完全可以利用redis的INCR、INCRBY、DECR、DECRBY等指令来实现原子计数的效果。
假如,在某种场景下有3个客户端同时读取了mynum的值(值为2),然后对其同时进行了加1的操作,那么,最后mynum的值一定是5。
列表类型(list)可以存储一个有序的字符串列表,常用的操作是向列表两端添加元素或者获得列表的某一个片段。
列表类型内部使用双向链表实现,所以向列表两端添加元素的时间复杂度为O(1), 获取越接近两端的元素速度就越
快。这意味着即使是一个有几千万个元素的列表,获取头部或尾部的10条记录也是很快的
内部数据结构
redis3.2之前,List类型的value对象内部以linkedlist或者ziplist来实现, 当list的元素个数和单个元素的长度比较小
的时候,Redis会采用ziplist(压缩列表)来实现来减少内存占用。否则就会采用linkedlist(双向链表)结构。
redis3.2之后,采用的一种叫quicklist的数据结构来存储list,列表的底层都由quicklist实现。
这两种存储方式都有优缺点,双向链表在链表两端进行push和pop操作,在插入节点上复杂度比较低,但是内存开
销比较大; ziplist存储在一段连续的内存上,所以存储效率很高,但是插入和删除都需要频繁申请和释放内存;
quicklist仍然是一个双向链表,只是列表的每个节点都是一个ziplist,其实就是linkedlist和ziplist的结合,quicklist
中每个节点ziplist都能够存储多个数据元素,在源码中的文件为【quicklist.c】,在源码第一行中有解释为:A
doubly linked list of ziplists意思为一个由ziplist组成的双向链表;
Hash
ziplist/hashtable
map提供两种结构来存储,一种是hashtable、另一种是前面讲的ziplist,数据量小的时候用ziplist. 在redis中,哈
希表分为三层,分别是
dictEntry
管理一个key-value,同时保留同一个桶中相邻元素的指针,用来维护哈希桶的内部链;
typedef struct dictEntry {
void *key;
union { //因为value有多种类型,所以value用了union来存储
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;//下一个节点的地址,用来处理碰撞,所有分配到同一索引的元素通过next指针
链接起来形成链表key和v都可以保存多种类型的数据
} dictEntry;
dictht
实现一个hash表会使用一个buckets存放dictEntry的地址,一般情况下通过hash(key)%len得到的值就是buckets的
索引,这个值决定了我们要将此dictEntry节点放入buckets的哪个索引里,这个buckets实际上就是我们说的hash
表。dict.h的dictht结构中table存放的就是buckets的地址
typedef struct dictht {
dictEntry **table;//buckets的地址
unsigned long size;//buckets的大小,总保持为 2^n
unsigned long sizemask;//掩码,用来计算hash值对应的buckets索
unsigned long used;//当前dictht有多少个dictEntry节} dictht;
dict
dictht实际上就是hash表的核心,但是只有一个dictht还不够,比如rehash、遍历hash等操作,所以redis定义了
一个叫dict的结构以支持字典的各种操作,当dictht需要扩容/缩容时,用来管理dictht的迁移,以下是它的数据结
构,源码在
typedef struct dict {
dictType *type;//dictType里存放的是一堆工具函数的函数指针,
void *privdata;//保存type中的某些函数需要作为参数的数据
dictht ht[2];//两个dictht,ht[0]平时用,ht[1] rehash时用
long rehashidx; //当前rehash到buckets的哪个索引,-1时表示非rehash状态
int iterators; //安全迭代器的计数。
} dict;
比如我们要讲一个数据存储到hash表中,那么会先通过murmur计算key对应的hashcode,然后根据hashcode取
模得到bucket的位置,再插入到链表中
Redis hash 是一个string类型的field和value的映射表,hash特别适合用于存储对象。
Redis 中每个 hash 可以存储 2^32 - 1 键值对(40多亿)
可以看成具有KEY和VALUE的MAP容器,该类型非常适合于存储值对象的信息, 如:uname,upass,age等。该类型的数据仅占用很少的磁盘空间(相比于JSON)
赋值语法:
HSET KEY FIELD VALUE //为指定的KEY,设定FILD/VALUE
HMSET KEY FIELD VALUE [FIELD1,VALUE1]…… 同时将多个 field-value (域-值)对设置到哈希表 key 中。
取值语法:
HGET KEY FIELD //获取存储在HASH中的值,根据FIELD得到VALUE
HMGET key field[field1] //获取key所有给定字段的值
HGETALL key //返回HASH表中所有的字段和值
HKEYS key //获取所有哈希表中的字段
HLEN key //获取哈希表中字段的数量
删除语法:
HDEL KEY field1[field2] //删除一个或多个HASH表字段
其它语法:
HSETNX key field value
只有在字段 field 不存在时,设置哈希表字段的值
HINCRBY key field increment
为哈希表 key 中的指定字段的整数值加上增量 increment 。
HINCRBYFLOAT key field increment
为哈希表 key 中的指定字段的浮点数值加上增量 increment
HEXISTS key field //查看哈希表 key 中,指定的字段是否存在
常用于存储一个对象
为什么不用string存储一个对象?
hash是最接近关系数据库结构的数据类型,可以将数据库一条记录或程序中一个对象转换成hashmap存放在redis中。
用户ID为查找的key,存储的value用户对象包含姓名,年龄,生日等信息,如果用普通的key/value结构来存储,主要有以下2种存储方式:
第一种方式将用户ID作为查找key,把其他信息封装成一个对象以序列化的方式存储,这种方式的缺点是,增加了序列化/反序列化的开销,并且在需要修改其中一项信息时,需要把整个对象取回,并且修改操作需要对并发进行保护,引入CAS等复杂问题。
第二种方法是这个用户信息对象有多少成员就存成多少个key-value对儿,用用户ID+对应属性的名称作为唯一标识来取得对应属性的值,虽然省去了序列化开销和并发问题,但是用户ID为重复存储,如果存在大量这样的数据,内存浪费还是非常可观的。
Redis提供的Hash很好的解决了这个问题,Redis的Hash实际是内部存储的Value为一个HashMap,并提供了直接存取这个Map成员的接口
Set
intset/hashtable
集合类型中,每个元素都是不同的,也就是不能有重复数据,同时集合类型中的数据是无序的。一个集合类型键可
以存储至多232-1个 。集合类型和列表类型的最大的区别是有序性和唯一性
集合类型的常用操作是向集合中加入或删除元素、判断某个元素是否存在。由于集合类型在redis内部是使用的值
为空的散列表(hash table),所以这些操作的时间复杂度都是O(1).
Set在的底层数据结构以intset或者hashtable来存储。当set中只包含整数型的元素时,采用intset来存储,否则,
采用hashtable存储,但是对于set来说,该hashtable的value值用于为NULL。通过key来存储元素
Zset
ziplist/skiplist
有序集合类型,顾名思义,和前面讲的集合类型的区别就是多了有序的功能
在集合类型的基础上,有序集合类型为集合中的每个元素都关联了一个分数,这使得我们不仅可以完成插入、删除
和判断元素是否存在等集合类型支持的操作,还能获得分数最高(或最低)的前N个元素、获得指定分数范围内的元
素等与分数有关的操作。虽然集合中每个元素都是不同的,但是他们的分数却可以相同
zset类型的数据结构就比较复杂一点,内部是以ziplist或者skiplist+hashtable来实现,这里面最核心的一个结构就
是skiplist,也就是跳跃表
跳跃表结构在 Redis 中的运用场景只有一个,那就是作为有序列表 Zset 的使用。跳跃表的性能可以保证在查找,删除,添加等操作的时候在对数期望时间内完成,这个性能是可以和平衡树来相比较的,而且在实现方面比平衡树要优雅,这就是跳跃表的长处。跳跃表的缺点就是需要的存储空间比较大,属于利用空间来换取时间的数据结构。接下来我们思考三个问题:
思考三个问题
跳跃表的底层结构是什么样的,为什么可以支撑它在对数期望时间内完成基本操作(增删改查)?
在跳跃表中,完成一个元素的增删改查的详细过程是怎样的?
利用跳跃表作为底层数据结构的有序列表,在实际的业务场景中有什么运用?
跳跃表结构
在跳跃表中,每个跳跃表的节点都会维护着一个 score 的值,这个值在跳跃表中是按照大小排好序的。
跳跃表的数据结构源代码
typedef struct zskiplist {
// 头节点,尾节点
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 目前表内节点的最大层数
int level;
} zskiplist;
header 指向了跳跃表的头结点,tail 指向跳跃表的尾节点
length 表示了跳跃表节点中的数量
level 表示跳跃表的表内节点的最大层数
跳跃表的节点结构如下图所示
typedef struct zskiplistNode {
// member 对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 这个层跨越的节点数量
unsigned int span;
} level[];
} zskiplistNode;
直观来感受下跳跃表结构
跳跃表节点图
跳跃表节点图
obj (成员对象):对应的是图中的 o1, o2, o3,是用来存储一个节点中的对象的。
score (分值):对应的是每一个成员对象中的 1.0,2.0 等分数值。
后退指针:这个指针指向的是前面的一个跳表节点。
层:这个结构包括前进指针和记录了跨越的节点数量,这块就是跳跃表的精髓所在。
跳跃表的基本结构就是上面所展示的部分,接下来我们开始进行分析跳跃表的基础操作过程(增删改查)
跳跃表增删查改过程
一个跳跃表的一个节点是 64 层,能够存储的节点数量应该 2^64 个。在源码中是这样的,官方没有其他的解释。
define ZSKIPLIST_MAXLEVEL 64 /* Should be enough for 2^64 elements */
查找过程:
按照图中所示,我们现在需要查找的是值为 7 的这个节点。步骤如下:
从 head 节点开始,为了演示方便,这里显示的是4层,实际上的是64层。先是降一层到值 4 这个节点的这一层。如果不是所需要的值,那么就再降一层,跳跃到值为 6 的这一层。最后查找到值为 7 。这就是查找的过程,时间复杂度为 O(lg(n))
插入过程:
插入的过程和查找的过程类似:比如要插入的值为 6
从 head 节点开始,先是在 head 开始降层来查找到最后一个比 6 小的节点,等到查到最后一个比 6 小的节点的时候(假设为 5 )。然后需要引入一个随机层数算法来为这个节点随机地建立层数。把这个节点插入进去以后,同时更新一遍最高的层数即可。
随机算法
/* Returns a random level for the new skiplist node we are going to create.
- The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL
- (both inclusive), with a powerlaw-alike distribution where higher
- levels are less likely to be returned. */
int zslRandomLevel(void) {
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
Redis 源码中的晋升概率为25%,所以相对来说,Redis 的层高数相对来说是比较扁平化,层高相对较低,所以需要遍历的节点数量会多一些。
删除过程:
删除的过程也是和查找的过程一样,先是找到要删除的那个值,再把这个值给删除,同时把重排一下指针和更新最高的层数。
更新过程:
更新的过程和插入的过程都是是使用着 zadd 方法的,先是判断这个 value 是否存在,如果存在就是更新的过程,如果不存在就是插入过程。在更新的过程是,如果找到了Value,先删除掉,再新增,这样的弊端是会做两次的搜索,在性能上来讲就比较慢了,在 Redis 5.0 版本中,Redis 的作者 Antirez 优化了这个更新的过程,目前的更新过程是如果判断这个 value是否存在,如果存在的话就直接更新,然后再调整整个跳跃表的 score 排序,这样就不需要两次的搜索过程。
可以看看关于 Antirez 这次的更新优化代码。
实际的业务场景
Zset 数据结构
如图所示,Zset 的数据结构是有一个 hash 表和一个跳跃表来结合的,hash 表上存储的是关于 String 的值和 Score 的值,跳跃表是用来辅助 hash 表来实现关于按照 score 来排序的功能。
所以跳跃表的实际运用场景就是 Zset 的实际运用场景
Zset的使用示例
//给某个集合增加权重和成员
//成员不可以为重复,权重可以重复,一个集合可以容纳到2^32-1个元素
//增加元素
redis 127.0.0.1:6379> ZADD spacedong 1 redis
redis 127.0.0.1:6379> ZADD spacedong 2 mongodb
redis 127.0.0.1:6379> ZADD spacedong 3 mysql
//获取集合中的元素个数
redis 127.0.0.1:6379> ZCARD spacedong
“3”
//获取集合中的某个范围的成员
redis 127.0.0.1:6379> ZRANGE spacedong 0 2
- “redis”
- “mongodb”
- “mysql”
Zset的实际运用场景
在 Zset 中使用最多的场景就是涉及到排行榜类似的场景。例如实时统计一个关于分数的排行榜,这个时候可以使用 Redis 中的这个 ZSET 数据结构来维护。
涉及到需要按照时间的顺序来排行的业务场景,例如如果需要维护一个问题池,按照时间的先后顺序来维护,这个时候也可以使用 Zset ,把时间当做权重,把问题当做 key 值来进行存取。
常用操作
过期时间设置
在Redis中提供了Expire命令设置一个键的过期时间,到期以后Redis会自动删除它。这个在我们实际使用过程中用
得非常多。
EXPIRE命令的使用方法为
EXPIRE key seconds
其中seconds 参数表示键的过期时间,单位为秒。
EXPIRE 返回值为1表示设置成功,0表示设置失败或者键不存在
如果向知道一个键还有多久时间被删除,可以使用TTL命令
TTL key
当键不存在时,TTL命令会返回-2
而对于没有给指定键设置过期时间的,通过TTL命令会返回-1
如果向取消键的过期时间设置(使该键恢复成为永久的),可以使用PERSIST命令,如果该命令执行成功或者成功
清除了过期时间,则返回1 。 否则返回0(键不存在或者本身就是永久的)
EXPIRE命令的seconds命令必须是整数,所以最小单位是1秒,如果向要更精确的控制键的过期时间可以使用
PEXPIRE命令,当然实际过程中用秒的单位就够了。 PEXPIRE命令的单位是毫秒。即PEXPIRE key 1000与EXPIRE
key 1相等;对应的PTTL以毫秒单位获取键的剩余有效时间
还有一个针对字符串独有的过期时间设置方式
setex(String key,int seconds,String value)
过期删除的原理
Redis 中的主键失效是如何实现的,即失效的主键是如何删除的?实际上,Redis 删除失效主键的方法主要有两
种:
消极方法(passive way)
在主键被访问时如果发现它已经失效,那么就删除它
积极方法(active way)
周期性地从设置了失效时间的主键中选择一部分失效的主键删除
对于那些从未被查询的key,即便它们已经过期,被动方式也无法清除。因此Redis会周期性地随机测试一些key,
已过期的key将会被删掉。Redis每秒会进行10次操作,具体的流程:
\1. 随机测试 20 个带有timeout信息的key;
\2. 删除其中已经过期的key;
\3. 如果超过25%的key被删除,则重复执行步骤1;
这是一个简单的概率算法(trivial probabilistic algorithm),基于假设我们随机抽取的key代表了全部的key空
间。
Redis发布订阅
Redis提供了发布订阅功能,可以用于消息的传输,Redis提供了一组命令可以让开发者实现“发布/订阅”模式
(publish/subscribe) . 该模式同样可以实现进程间的消息传递,它的实现原理是
发布/订阅模式包含两种角色,分别是发布者和订阅者。订阅者可以订阅一个或多个频道,而发布者可以向指定的
频道发送消息,所有订阅此频道的订阅者都会收到该消息
发布者发布消息的命令是PUBLISH, 用法是
PUBLISH channel message
比如向channel.1发一条消息:hello
PUBLISH channel.1 “hello”
这样就实现了消息的发送,该命令的返回值表示接收到这条消息的订阅者数量。因为在执行这条命令的时候还没有
订阅者订阅该频道,所以返回为0. 另外值得注意的是消息发送出去不会持久化,如果发送之前没有订阅者,那么后
续再有订阅者订阅该频道,之前的消息就收不到了
订阅者订阅消息的命令是
SUBSCRIBE channel [channel …]
该命令同时可以订阅多个频道,比如订阅channel.1的频道。 SUBSCRIBE channel.1
执行SUBSCRIBE命令后客户端会进入订阅状态
channel分两类,一个是普通channel、另一个是pattern channel(规则匹配), producer1发布了一条消息
【publish abc hello】,redis server发给abc这个普通channel上的所有订阅者,同时abc也匹配上了pattern
channel的名字,所以这条消息也会同时发送给pattern channel *bc上的所有订阅者
Lua
Redis中内嵌了对Lua环境的支持,允许开发者使用Lua语言编写脚本传到Redis中执行,Redis客户端可以使用Lua
脚本,直接在服务端原子的执行多个Redis命令。
使用脚本的好处:
\1. 减少网络开销,在Lua脚本中可以把多个命令放在同一个脚本中运行
\2. 原子操作,redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。换句话说,编写脚本的过程中无
需担心会出现竞态条件
\3. 复用性,客户端发送的脚本会永远存储在redis中,这意味着其他客户端可以复用这一脚本来完成同样的逻辑
Lua是一个高效的轻量级脚本语言(javascript、shell、sql、python、ruby…),用标准C语言编写并以源代码形式开
放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能;
在Lua脚本中调用Redis命令
在Lua脚本中调用Redis命令,可以使用redis.call函数调用。比如我们调用string类型的命令
redis.call(‘set’,’hello’,’world’)
local value=redis.call(‘get’,’hello’)
redis.call 函数的返回值就是redis命令的执行结果。前面我们介绍过redis的5中类型的数据返回的值的类型也都不
一样。redis.call函数会将这5种类型的返回值转化对应的Lua的数据类型
从Lua脚本中获得返回值
在很多情况下我们都需要脚本可以有返回值,毕竟这个脚本也是一个我们所编写的命令集,我们可以像调用其他
redis内置命令一样调用我们自己写的脚本,所以同样redis会自动将脚本返回值的Lua数据类型转化为Redis的返回
值类型。 在脚本中可以使用return 语句将值返回给redis客户端,通过return语句来执行,如果没有执行return,
默认返回为nil。
EVAL命令的格式是
[EVAL][脚本内容] [key参数的数量][key …] [arg …]
可以通过key和arg这两个参数向脚本中传递数据,他们的值可以在脚本中分别使用KEYS和ARGV 这两个类型的全
局变量访问。比如我们通过脚本实现一个set命令,通过在redis客户端中调用,那么执行的语句是:
lua脚本的内容为: return redis.call(‘set’,KEYS[1],ARGV[1]) //KEYS和ARGV必须大写
eval “return redis.call(‘set’,KEYS[1],ARGV[1])” 1 lua1 hello
注意:EVAL命令是根据 key参数的数量-也就是上面例子中的1来将后面所有参数分别存入脚本中KEYS和ARGV两个
表类型的全局变量。当脚本不需要任何参数时也不能省略这个参数。如果没有参数则为0
EVALSHA命令
考虑到我们通过eval执行lua脚本,脚本比较长的情况下,每次调用脚本都需要把整个脚本传给redis,比较占用带
宽。为了解决这个问题,redis提供了EVALSHA命令允许开发者通过脚本内容的SHA1摘要来执行脚本。该命令的用
法和EVAL一样,只不过是将脚本内容替换成脚本内容的SHA1摘要
\1. Redis在执行EVAL命令时会计算脚本的SHA1摘要并记录在脚本缓存中。
\2. 执行EVALSHA命令时Redis会根据提供的摘要从脚本缓存中查找对应的脚本内容,如果找到了就执行脚本,否则
返回“NOSCRIPT No matching script,Please use EVAL”
通过以下案例来演示EVALSHA命令的效果
script load “return redis.call(‘get’,‘lua1’)” 将脚本加入缓存并生成sha1命令
evalsha “a5a402e90df3eaeca2ff03d56d99982e05cf6574” 0
我们在调用eval命令之前,先执行evalsha命令,如果提示脚本不存在,则再调用eval命令
分布式
主从复制
复制的作用是把redis的数据库复制多个副本部署在不同的服务器上,如果其中一台服务器出现故障,也能快速迁
移到其他服务器上提供服务。 复制功能可以实现当一台redis服务器的数据更新后,自动将新的数据同步到其他服
务器上
主从复制就是我们常见的master/slave模式, 主数据库可以进行读写操作,当写操作导致数据发生变化时会自动将
数据同步给从数据库。而一般情况下,从数据库是只读的,并接收主数据库同步过来的数据。 一个主数据库可以有
多个从数据库
在redis中配置master/slave是非常容易的,只需要在从数据库的配置文件中加入slaveof 主数据库地址 端口。 而
master 数据库不需要做任何改变。
准备两台服务器,分别安装redis , server1 server2
\1. 在server2的redis.conf文件中增加 slaveof server1-ip 6379 、 同时将bindip注释掉,允许所
有ip访问
\2. 启动server2
\3. 访问server2的redis客户端,输入 INFO replication
\4. 通过在master机器上输入命令,比如set foo bar 、 在slave服务器就能看到该值已经同步过来
全量复制
Redis全量复制一般发生在Slave初始化阶段,这时Slave需要将Master上的所有数据都复制一份。具体步骤
完成上面几个步骤后就完成了slave服务器数据初始化的所有操作,savle服务器此时可以接收来自用户的读请求。
master/slave 复制策略是采用乐观复制,也就是说可以容忍在一定时间内master/slave数据的内容是不同的,但是
两者的数据会最终同步。具体来说,redis的主从同步过程本身是异步的,意味着master执行完客户端请求的命令
后会立即返回结果给客户端,然后异步的方式把命令同步给slave。
这一特征保证启用master/slave后 master的性能不会受到影响。
但是另一方面,如果在这个数据不一致的窗口期间,master/slave因为网络问题断开连接,而这个时候,master
是无法得知某个命令最终同步给了多少个slave数据库。不过redis提供了一个配置项来限制只有数据至少同步给多
少个slave的时候,master才是可写的:
min-slaves-to-write 3 表示只有当3个或以上的slave连接到master,master才是可写的
min-slaves-max-lag 10 表示允许slave最长失去连接的时间,如果10秒还没收到slave的响应,则master认为该
slave以断开
增量复制
从redis 2.8开始,就支持主从复制的断点续传,如果主从复制过程中,网络连接断掉了,那么可以接着上次复制的
地方,继续复制下去,而不是从头开始复制一份
master node会在内存中创建一个backlog,master和slave都会保存一个replica offset还有一个master id,offset
就是保存在backlog中的。如果master和slave网络连接断掉了,slave会让master从上次的replica offset开始继续
复制
但是如果没有找到对应的offset,那么就会执行一次全量同步
无硬盘复制
前面我们说过,Redis复制的工作原理基于RDB方式的持久化实现的,也就是master在后台保存RDB快照,slave接
收到rdb文件并载入,但是这种方式会存在一些问题
\1. 当master禁用RDB时,如果执行了复制初始化操作,Redis依然会生成RDB快照,当master下次启动时执行该
RDB文件的恢复,但是因为复制发生的时间点不确定,所以恢复的数据可能是任何时间点的。就会造成数据出现问
题
\2. 当硬盘性能比较慢的情况下(网络硬盘),那初始化复制过程会对性能产生影响
因此2.8.18以后的版本,Redis引入了无硬盘复制选项,可以不需要通过RDB文件去同步,直接发送数据,通过以
下配置来开启该功能
repl-diskless-sync yes
master**在内存中直接创建rdb,然后发送给slave,不会在自己本地落地磁盘了
哨兵机制
在前面讲的master/slave模式,在一个典型的一主多从的系统中,slave在整个体系中起到了数据冗余备份和读写
分离的作用。当master遇到异常终端后,需要从slave中选举一个新的master继续对外提供服务,这种机制在前面
提到过N次,比如在zk中通过leader选举、kafka中可以基于zk的节点实现master选举。所以在redis中也需要一种
机制去实现master的决策,redis并没有提供自动master选举功能,而是需要借助一个哨兵来进行监控
顾名思义,哨兵的作用就是监控Redis系统的运行状况,它的功能包括两个
\1. 监控master和slave是否正常运行
\2. master出现故障时自动将slave数据库升级为master
哨兵是一个独立的进程,使用哨兵后的架构图
为了解决master选举问题,又引出了一个单点问题,也就是哨兵的可用性如何解决,在一个一主多从的Redis系统
中,可以使用多个哨兵进行监控任务以保证系统足够稳定。此时哨兵不仅会监控master和slave,同时还会互相监
控;这种方式称为哨兵集群,哨兵集群需要解决故障发现、和master决策的协商机制问题。
sentinel之间的相互感知
sentinel节点之间会因为共同监视同一个master从而产生了关联,一个新加入的sentinel节点需要和其他监视相同
master节点的sentinel相互感知,首先
\1. 需要相互感知的sentinel都向他们共同监视的master节点订阅channel:sentinel:hello
\2. 新加入的sentinel节点向这个channel发布一条消息,包含自己本身的信息,这样订阅了这个channel的sentinel
就可以发现这个新的sentinel
\3. 新加入得sentinel和其他sentinel节点建立长连接
master的故障发现
sentinel节点会定期向master节点发送心跳包来判断存活状态,一旦master节点没有正确响应,sentinel会把
master设置为“主观不可用状态”,然后它会把“主观不可用”发送给其他所有的sentinel节点去确认,当确认的
sentinel节点数大于>quorum时,则会认为master是“客观不可用”,接着就开始进入选举新的master流程;但是
这里又会遇到一个问题,就是sentinel中,本身是一个集群,如果多个节点同时发现master节点达到客观不可用状
态,那谁来决策选择哪个节点作为maste呢?这个时候就需要从sentinel集群中选择一个leader来做决策。而这里
用到了一致性算法Raft算法、它和Paxos算法类似,都是分布式一致性算法。但是它比Paxos算法要更容易理解;
Raft和Paxos算法一样,也是基于投票算法,只要保证过半数节点通过提议即可;
动画演示地址:http://thesecretlivesofdata.com/raft/
配置实现
通过在这个配置的基础上增加哨兵机制。在其中任意一台服务器上创建一个sentinel.conf文件,文件内容
sentinel monitor name ip port quorum
其中name表示要监控的master的名字,这个名字是自己定义。 ip和port表示master的ip和端口号。 最后一个1表示最低
通过票数,也就是说至少需要几个哨兵节点统一才可以,后面会具体讲解
port 6040
sentinel monitor mymaster 192.168.11.131 6379 1
sentinel down-after-milliseconds mymaster 5000 --表示如果5s内mymaster没响应,就认为SDOWN
sentinel failover-timeout mymaster 15000 --表示如果15秒后,mysater仍没活过来,则启动failover,从剩下的
slave中选一个升级为master
两种方式启动哨兵
redis-sentinel sentinel.conf
redis-server /path/to/sentinel.conf --sentinel
哨兵监控一个系统时,只需要配置监控master即可,哨兵会自动发现所有slave;
这时候,我们把master关闭,等待指定时间后(默认是30秒),会自动进行切换,会输出如下消息
img
+sdown表示哨兵主管认为master已经停止服务了,+odown表示哨兵客观认为master停止服务了。关于主观和客
观,后面会给大家讲解。接着哨兵开始进行故障恢复,挑选一个slave升级为master
+try-failover表示哨兵开始进行故障恢复
+failover-end 表示哨兵完成故障恢复
+slave表示列出新的master和slave服务器,我们仍然可以看到已经停掉的master,哨兵并没有清楚已停止的服务
的实例,这是因为已经停止的服务器有可能会在某个时间进行恢复,恢复以后会以slave角色加入到整个集群中
Redis-Cluster
即使是使用哨兵,此时的Redis集群的每个数据库依然存有集群中的所有数据,从而导致集群的总数据存储量受限
于可用存储内存最小的节点,形成了木桶效应。而因为Redis是基于内存存储的,所以这一个问题在redis中就显得
尤为突出了
在redis3.0之前,我们是通过在客户端去做的分片,通过hash环的方式对key进行分片存储。分片虽然能够解决各
个节点的存储压力,但是导致维护成本高、增加、移除节点比较繁琐。因此在redis3.0以后的版本最大的一个好处
就是支持集群功能,集群的特点在于拥有和单机实例一样的性能,同时在网络分区以后能够提供一定的可访问性以
及对主数据库故障恢复的支持。
哨兵和集群是两个独立的功能,当不需要对数据进行分片使用哨兵就够了,如果要进行水平扩容,集群是一个比较好的方式
拓扑结构
一个Redis Cluster由多个Redis节点构成。不同节点组服务的数据没有交集,也就是每个一节点组对应数据
sharding的一个分片。节点组内部分为主备两类节点,对应master和slave节点。两者数据准实时一致,通过异步
化的主备复制机制来保证。一个节点组有且只有一个master节点,同时可以有0到多个slave节点,在这个节点组中
只有master节点对用户提供些服务,读服务可以由master或者slave提供
3.png
redis-cluster是基于gossip协议实现的无中心化节点的集群,因为去中心化的架构不存在统一的配置中心,各个节
点对整个集群状态的认知来自于节点之间的信息交互。在Redis Cluster,这个信息交互是通过Redis Cluster Bus来
完成的
Redis的数据分区
分布式数据库首要解决把整个数据集按照分区规则映射到多个节点的问题,即把数据集划分到多个节点上,每个节
点负责整个数据的一个子集, Redis Cluster采用哈希分区规则,采用虚拟槽分区。
虚拟槽分区巧妙地使用了哈希空间,使用分散度良好的哈希函数把所有的数据映射到一个固定范围内的整数集合,
整数定义为槽(slot)。比如Redis Cluster槽的范围是0 ~ 16383。槽是集群内数据管理和迁移的基本单位。采用
大范围的槽的主要目的是为了方便数据的拆分和集群的扩展,每个节点负责一定数量的槽。
计算公式:slot = CRC16(key)%16383。每一个节点负责维护一部分槽以及槽所映射的键值数据。
HashTags
通过分片手段,可以将数据合理的划分到不同的节点上,这本来是一件好事。但是有的时候,我们希望对相关联的
业务以原子方式进行操作。举个简单的例子
我们在单节点上执行MSET , 它是一个原子性的操作,所有给定的key会在同一时间内被设置,不可能出现某些指定
的key被更新另一些指定的key没有改变的情况。但是在集群环境下,我们仍然可以执行MSET命令,但它的操作不
在是原子操作,会存在某些指定的key被更新,而另外一些指定的key没有改变,原因是多个key可能会被分配到不
同的机器上。
所以,这里就会存在一个矛盾点,及要求key尽可能的分散在不同机器,又要求某些相关联的key分配到相同机器。
这个也是在面试的时候会容易被问到的内容。怎么解决呢?
从前面的分析中我们了解到,分片其实就是一个hash的过程,对key做hash取模然后划分到不同的机器上。所以为
了解决这个问题,我们需要考虑如何让相关联的key得到的hash值都相同呢?如果key全部相同是不现实的,所以
怎么解决呢?在redis中引入了HashTag的概念,可以使得数据分布算法可以根据key的某一个部分进行计算,然后
让相关的key落到同一个数据分片
举个简单的例子,加入对于用户的信息进行存储, user:user1:id、user:user1:name/ 那么通过hashtag的方式,
user:{user1}:id、user:{user1}.name; 表示
当一个key包含 {} 的时候,就不对整个key做hash,而仅对 {} 包括的字符串做hash。
重定向客户端
Redis Cluster并不会代理查询,那么如果客户端访问了一个key并不存在的节点,这个节点是怎么处理的呢?比如
我想获取key为msg的值,msg计算出来的槽编号为254,当前节点正好不负责编号为254的槽,那么就会返回客户
端下面信息:
-MOVED 254 127.0.0.1:6381
表示客户端想要的254槽由运行在IP为127.0.0.1,端口为6381的Master实例服务。如果根据key计算得出的槽恰好
由当前节点负责,则当期节点会立即返回结果
分片迁移
在一个稳定的Redis cluster下,每一个slot对应的节点是确定的,但是在某些情况下,节点和分片对应的关系会发
生变更
\1. 新加入master节点
\2. 某个节点宕机
也就是说当动态添加或减少node节点时,需要将16384个槽做个再分配,槽中的键值也要迁移。当然,这一过程,
在目前实现中,还处于半自动状态,需要人工介入。
新增一个主节点
新增一个节点D,redis cluster的这种做法是从各个节点的前面各拿取一部分slot到D上。大致就会变成这样:
节点A覆盖1365-5460
节点B覆盖6827-10922
节点C覆盖12288-16383
节点D覆盖0-1364,5461-6826,10923-12287
删除一个主节点
先将节点的数据移动到其他节点上,然后才能执行删除
槽迁移的过程
槽迁移的过程中有一个不稳定状态,这个不稳定状态会有一些规则,这些规则定义客户端的行为,从而使得Redis
Cluster不必宕机的情况下可以执行槽的迁移。下面这张图描述了我们迁移编号为1、2、3的槽的过程中,他们在
MasterA节点和MasterB节点中的状态。
简单的工作流程
\1. 向MasterB发送状态变更命令,吧Master B对应的slot状态设置为IMPORTING
\2. 向MasterA发送状态变更命令,将Master对应的slot状态设置为MIGRATING
当MasterA的状态设置为MIGRANTING后,表示对应的slot正在迁移,为了保证slot数据的一致性,MasterA此时
对于slot内部数据提供读写服务的行为和通常状态下是有区别的,
MIGRATING状态
\1. 如果客户端访问的Key还没有迁移出去,则正常处理这个key
\2. 如果key已经迁移或者根本就不存在这个key,则回复客户端ASK信息让它跳转到MasterB去执行
IMPORTING状态
当MasterB的状态设置为IMPORTING后,表示对应的slot正在向MasterB迁入,及时Master仍然能对外提供该slot
的读写服务,但和通常状态下也是有区别的
\1. 当来自客户端的正常访问不是从ASK跳转过来的,说明客户端还不知道迁移正在进行,很有可能操作了一个目前
还没迁移完成的并且还存在于MasterA上的key,如果此时这个key在A上已经被修改了,那么B和A的修改则会发生
冲突。所以对于MasterB上的slot上的所有非ASK跳转过来的操作,MasterB都不会uu出去护理,而是通过MOVED
命令让客户端跳转到MasterA上去执行
这样的状态控制保证了同一个key在迁移之前总是在源节点上执行,迁移后总是在目标节点上执行,防止出现两边
同时写导致的冲突问题。而且迁移过程中新增的key一定会在目标节点上执行,源节点也不会新增key,是的整个迁
移过程既能对外正常提供服务,又能在一定的时间点完成slot的迁移。
其他
布隆过滤器
是Burton Howard Bloom在1970年提出来的,一种空间效率极高的概率型算法和数据结构,主要用来
判断一个元素是否在集合中存在。因为他是一个概率型的算法,所以会存在一定的误差,如果传入一个值去布隆过
滤器中检索,可能会出现检测存在的结果但是实际上可能是不存在的,但是肯定不会出现实际上不存在然后反馈存
在的结果。因此,Bloom Filter不适合那些“零错误”的应用场合。而在能容忍低错误率的应用场合下,Bloom Filter
通过极少的错误换取了存储空间的极大节省。
bitmap
所谓的Bit-map就是用一个bit位来标记某个元素对应的Value,通过Bit为单位来存储数据,可以大大节省存储空间.
所以我们可以通过一个int型的整数的32比特位来存储32个10进制的数字,那么这样所带来的好处是内存占用少、
效率很高(不需要比较和位移)比如我们要存储5(101)、3(11)四个数字,那么我们申请int型的内存空间,会有32
个比特位。这四个数字的二进制分别对应
从右往左开始数,比如第一个数字是5,对应的二进制数据是101, 那么从右往左数到第5位,把对应的二进制数据
存储到32个比特位上。
第一个5就是 00000000000000000000000000101000
输入3时候 00000000000000000000000000001100
布隆过滤器原理
有了对位图的理解以后,我们对布隆过滤器的原理理解就会更容易了,仍然以前面提到的40亿数据为案例,假设这
40亿数据为某邮件服务器的黑名单数据,邮件服务需要根据邮箱地址来判断当前邮箱是否属于垃圾邮件。原理如下
假设集合里面有3个元素{x, y, z},哈希函数的个数为3。首先将位数组进行初始化,将里面每个位都设置位0。对于
集合里面的每一个元素,将元素依次通过3个哈希函数进行映射,每次映射都会产生一个哈希值,这个值对应位数
组上面的一个点,然后将位数组对应的位置标记为1。查询W元素是否存在集合中的时候,同样的方法将W通过哈
希映射到位数组上的3个点。如果3个点的其中有一个点不为1,则可以判断该元素一定不存在集合中。反之,如果
3个点都为1,则该元素可能存在集合中
接下来按照该方法处理所有的输入对象,每个对象都可能把bitMap中一些白位置涂黑,也可能会遇到已经涂黑的
位置,遇到已经为黑的让他继续为黑即可。处理完所有的输入对象之后,在bitMap中可能已经有相当多的位置已
经被涂黑。至此,一个布隆过滤器生成完成,这个布隆过滤器代表之前所有输入对象组成的集合。
如何去判断一个元素是否存在bit array中呢? 原理是一样,根据k个哈希函数去得到的结果,如果所有的结果都是
1,表示这个元素可能(假设某个元素通过映射对应下标为4,5,6这3个点。虽然这3个点都为1,但是很明显这3
个点是不同元素经过哈希得到的位置,因此这种情况说明元素虽然不在集合中,也可能对应的都是1)存在。 如果
一旦发现其中一个比特位的元素是0,表示这个元素一定不存在
至于k个哈希函数的取值为多少,能够最大化的降低错误率(因为哈希函数越多,映射冲突会越少),这个地方就
会涉及到最优的哈希函数个数的一个算法逻辑
客户端
Jedis是Redis的Java实现的客户端,其API提供了比较全面的Redis命令的支持Redisson实现了分布式和可扩展的Java数据结构,和Jedis相比,功能较为简单,不支持字符串操作,不支持排
序、事务、管道、分区等Redis特性。Redisson主要是促进使用者对Redis的关注分离,从而让使用者能够将精力更
集中地放在处理业务逻辑上。
lettuce是基于Netty构建的一个可伸缩的线程安全的Redis客户端,支持同步、异步、响应式模式。多个线程可以
共享一个连接实例,而不必担心多线程并发问题;
jedis-sentinel原理分析
原理
客户端通过连接到哨兵集群,通过发送Protocol.SENTINEL_GET_MASTER_ADDR_BY_NAME 命令,从哨兵机器中
询问master节点的信息,拿到master节点的ip和端口号以后,再到客户端发起连接。连接以后,需要在客户端建
立监听机制,当master重新选举之后,客户端需要重新连接到新的master节点
Jedis-cluster原理分析
连接方式
Set hostAndPorts=new HashSet<>();
HostAndPort hostAndPort=new HostAndPort(“192.168.11.153”,7000);
HostAndPort hostAndPort1=new HostAndPort(“192.168.11.153”,7001);
HostAndPort hostAndPort2=new HostAndPort(“192.168.11.154”,7003);
HostAndPort hostAndPort3=new HostAndPort(“192.168.11.157”,7006);
hostAndPorts.add(hostAndPort);
hostAndPorts.add(hostAndPort1);
hostAndPorts.add(hostAndPort2);
hostAndPorts.add(hostAndPort3);
JedisCluster jedisCluster=new JedisCluster(hostAndPorts,6000);
jedisCluster.set(“mic”,“hello”)
原理分析
程序启动初始化集群环境
1)、读取配置文件中的节点配置,无论是主从,无论多少个,只拿第一个,获取redis连接实例
2)、用获取的redis连接实例执行clusterNodes()方法,实际执行redis服务端cluster nodes命令,获取主从配置信
息
3)、解析主从配置信息,先把所有节点存放到nodes的map集合中,key为节点的ip:port,value为当前节点的
jedisPool
4)、解析主节点分配的slots区间段,把slot对应的索引值作为key,第三步中拿到的jedisPool作为value,存储在
slots的map集合中
就实现了slot槽索引值与jedisPool的映射,这个jedisPool包含了master的节点信息,所以槽和几点是对应的,与
redis服务端一致
从集群环境存取值
1)、把key作为参数,执行CRC16算法,获取key对应的slot值
2)、通过该slot值,去slots的map集合中获取jedisPool实例
3)、通过jedisPool实例获取jedis实例,最终完成redis数据存取工作
Redisson客户端
redis-cluster连接方式
Config config=new Config();
config.useClusterServers().setScanInterval(2000).
addNodeAddress(“redis://192.168.11.153:7000”,
“redis://192.168.11.153:7001”,
“redis://192.168.11.154:7003”,“redis://192.168.11.157:7006”)RedissonClient redissonClient= Redisson.create(config);
RBucket rBucket=redissonClient.getBucket(“mic”);
System.out.println(rBucket.get());
getBucket-> 获取字符串对象;
getMap -> 获取map对象
getSortedSet->获取有序集合
getSet -> 获取集合
getList ->获取列表
淘汰策略
Redis有六种淘汰策略
volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
no-enviction(驱逐):禁止驱逐数据
注意这里的6种机制,volatile和allkeys规定了是对已设置过期时间的数据集淘汰数据还是从全部数据集淘汰数据,后面的lru、ttl以及random是三种不同的淘汰策略,再加上一种no-enviction永不回收的策略。
使用策略规则:
如果数据呈现幂律分布,也就是一部分数据访问频率高,一部分数据访问频率低,则使用allkeys-lru
如果数据呈现平等分布,也就是所有的数据访问频率都相同,则使用allkeys-random
三种数据淘汰策略:ttl和random比较容易理解,实现也会比较简单。主要是Lru最近最少使用淘汰策略,设计上会对key 按失效时间排序,然后取最先失效的key进行淘汰
补充一下:Redis4.0加入了LFU(least frequency use)淘汰策略,包括volatile-lfu和allkeys-lfu,通过统计访问频率,将访问频率最少,即最不经常使用的KV淘汰。
volatile-lru:从已设置过期时间的数据集(server. db[i]. expires)中挑选最近最少使用的数据淘汰。
volatile-ttl:从已设置过期时间的数据集(server. db[i]. expires)中挑选将要过期的数据淘汰。
volatile-random:从已设置过期时间的数据集(server. db[i]. expires)中任意选择数据淘汰。
allkeys-lru:从数据集(server. db[i]. dict)中挑选最近最少使用的数据淘汰。
allkeys-random:从数据集(server. db[i]. dict)中任意选择数据淘汰。
no-enviction(驱逐):禁止驱逐数据。
redis常见性能问题和解决方案
1.Master写内存快照,save命令调度rdbSave函数,会阻塞主线程的工作,当快照比较大时对性能影响是非常大的,会间断性暂停服务,所以Master最好不要写内存快照。2.Master AOF持久化,如果不重写AOF文件,这个持久化方式对性能的影响是最小的,但是AOF文件会不断增大,AOF文件过大会影响Master重启的恢复速度。Master最好不要做任何持久化工作,包括内存快照和AOF日志文件,特别是不要启用内存快照做持久化,如果数据比较关键,某个Slave开启AOF备份数据,策略为每秒同步一次。3.Master调用BGREWRITEAOF重写AOF文件,AOF在重写的时候会占大量的CPU和内存资源,导致服务load过高,出现短暂服务暂停现象。4.Redis主从复制的性能问题,为了主从复制的速度和连接的稳定性,Slave和Master最好在同一个局域网内。
redis的缓存失效策略和主键失效机制
作为缓存系统都要定期清理无效数据,就需要一个主键失效和淘汰策略.
在Redis当中,有生存期的key被称为volatile。在创建缓存时,要为给定的key设置生存期,当key过期的时候(生存期为0),它可能会被删除。
1、影响生存时间的一些操作生存时间可以通过使用 DEL 命令来删除整个 key 来移除,或者被 SET 和 GETSET 命令覆盖原来的数据,也就是说,修改key对应的value和使用另外相同的key和value来覆盖以后,当前数据的生存时间不同。比如说,对一个 key 执行INCR命令,对一个列表进行LPUSH命令,或者对一个哈希表执行HSET命令,这类操作都不会修改 key 本身的生存时间。另一方面,如果使用RENAME对一个 key 进行改名,那么改名后的 key的生存时间和改名前一样。RENAME命令的另一种可能是,尝试将一个带生存时间的 key 改名成另一个带生存时间的 another_key ,这时旧的 another_key (以及它的生存时间)会被删除,然后旧的 key 会改名为 another_key ,因此,新的 another_key 的生存时间也和原本的 key 一样。使用PERSIST命令可以在不删除 key 的情况下,移除 key 的生存时间,让 key 重新成为一个persistent key 。2、如何更新生存时间可以对一个已经带有生存时间的 key 执行EXPIRE命令,新指定的生存时间会取代旧的生存时间。过期时间的精度已经被控制在1ms之内,主键失效的时间复杂度是O(1),EXPIRE和TTL命令搭配使用,TTL可以查看key的当前生存时间。设置成功返回 1;当 key 不存在或者不能为 key 设置生存时间时,返回 0 。最大缓存配置,在 redis 中,允许用户设置最大使用内存大小server.maxmemory默认为0,没有指定最大缓存,如果有新的数据添加,超过最大内存,则会使redis崩溃,所以一定要设置。redis 内存数据集大小上升到一定大小的时候,就会实行数据淘汰策略。
redis事物的了解CAS(check-and-set 操作实现乐观锁 )?
和众多其它数据库一样,Redis作为NoSQL数据库也同样提供了事务机制。在Redis中,MULTI/EXEC/DISCARD/WATCH这四个命令是我们实现事务的基石。相信对有关系型数据库开发经验的开发者而言这一概念并不陌生,即便如此,我们还是会简要的列出Redis中事务的实现特征:1). 在事务中的所有命令都将会被串行化的顺序执行,事务执行期间,Redis不会再为其它客户端的请求提供任何服务,从而保证了事物中的所有命令被原子的执行。2). 和关系型数据库中的事务相比,在Redis事务中如果有某一条命令执行失败,其后的命令仍然会被继续执行。3). 我们可以通过MULTI命令开启一个事务,有关系型数据库开发经验的人可以将其理解为"BEGIN TRANSACTION"语句。在该语句之后执行的命令都将被视为事务之内的操作,最后我们可以通过执行EXEC/DISCARD命令来提交/回滚该事务内的所有操作。这两个Redis命令可被视为等同于关系型数据库中的COMMIT/ROLLBACK语句。4). 在事务开启之前,如果客户端与服务器之间出现通讯故障并导致网络断开,其后所有待执行的语句都将不会被服务器执行。然而如果网络中断事件是发生在客户端执行EXEC命令之后,那么该事务中的所有命令都会被服务器执行。5). 当使用Append-Only模式时,Redis会通过调用系统函数write将该事务内的所有写操作在本次调用中全部写入磁盘。然而如果在写入的过程中出现系统崩溃,如电源故障导致的宕机,那么此时也许只有部分数据被写入到磁盘,而另外一部分数据却已经丢失。Redis服务器会在重新启动时执行一系列必要的一致性检测,一旦发现类似问题,就会立即退出并给出相应的错误提示。此时,我们就要充分利用Redis工具包中提供的redis-check-aof工具,该工具可以帮助我们定位到数据不一致的错误,并将已经写入的部分数据进行回滚。修复之后我们就可以再次重新启动Redis服务器了。
13.WATCH命令和基于CAS的乐观锁?
在Redis的事务中,WATCH命令可用于提供CAS(check-and-set)功能。假设我们通过WATCH命令在事务执行之前监控了多个Keys,倘若在WATCH之后有任何Key的值发生了变化,EXEC命令执行的事务都将被放弃,同时返回Null multi-bulk应答以通知调用者事务执行失败。例如,我们再次假设Redis中并未提供incr命令来完成键值的原子性递增,如果要实现该功能,我们只能自行编写相应的代码。其伪码如下:
val = GET mykey
val = val + 1
SET mykey $val
以上代码只有在单连接的情况下才可以保证执行结果是正确的,因为如果在同一时刻有多个客户端在同时执行该段代码,那么就会出现多线程程序中经常出现的一种错误场景–竞态争用(race condition)。比如,客户端A和B都在同一时刻读取了mykey的原有值,假设该值为10,此后两个客户端又均将该值加一后set回Redis服务器,这样就会导致mykey的结果为11,而不是我们认为的12。为了解决类似的问题,我们需要借助WATCH命令的帮助,见如下代码:
WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC
和此前代码不同的是,新代码在获取mykey的值之前先通过WATCH命令监控了该键,此后又将set命令包围在事务中,这样就可以有效的保证每个连接在执行EXEC之前,如果当前连接获取的mykey的值被其它连接的客户端修改,那么当前连接的EXEC命令将执行失败。这样调用者在判断返回值后就可以获悉val是否被重新设置成功。
删除策略
redis过期key的清理策略
一,有三种不同的删除策略
(1),立即清理。在设置键的过期时间时,创建一个回调事件,当过期时间达到时,由时间处理器自动执行键的删除操作。
(2),惰性清理。键过期了就过期了,不管。当读/写一个已经过期的key时,会触发惰性删除策略,直接删除掉这个过期key
(3),定期清理。每隔一段时间,对expires字典进行检查,删除里面的过期键。
二,详细说明三种清理方式的优劣
(1)立即清理
立即删除能保证内存中数据的最大新鲜度,因为它保证过期键值会在过期后马上被删除,其所占用的内存也会随之释放。但是立即删除对cpu是最不友好的。
因为删除操作会占用cpu的时间,如果刚好碰上了cpu很忙的时候,比如正在做交集或排序等计算的时候,就会给cpu造成额外的压力。
(2)惰性删除
惰性删除是指,某个键值过期后,此键值不会马上被删除,而是等到下次被使用的时候,才会被检查到过期,此时才能得到删除。
所以惰性删除的缺点很明显:浪费内存。dict字典和expires字典都要保存这个键值的信息。
(3)定时删除
从上面分析来看,立即删除会短时间内占用大量cpu,惰性删除会在一段时间内浪费内存,所以定时删除是一个折中的办法。
定时删除是:每隔一段时间执行一次删除操作,并通过限制删除操作执行的时长和频率,来减少删除操作对cpu的影响。
另一方面定时删除也有效的减少了因惰性删除带来的内存浪费。
三,目前redis使用的过期键值删除策略是:惰性删除加上定期删除,两者配合使用。
四,详细说明定时清理机制
这个和redis.conf 的hz 10配置有关。
首先说一下时间事件,对于持续运行的服务器来说, 服务器需要定期对自身的资源和状态进行必要的检查和整理,
从而让服务器维持在一个健康稳定的状态, 这类操作被统称为常规操作(cron job)
在 Redis 中, 常规操作由 redis.c/serverCron 实现, 它主要执行以下操作
•更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等。
•清理数据库中的过期键值对。
•关闭和清理连接失效的客户端。
•尝试进行 AOF 或 RDB 持久化操作。
•如果服务器是主节点的话,对附属节点进行定期同步。
•如果处于集群模式的话,对集群进行定期同步和连接测试。
Redis 将 serverCron 作为时间事件来运行, 从而确保它每隔一段时间就会自动运行一次,
又因为 serverCron 需要在 Redis 服务器运行期间一直定期运行, 所以它是一个循环时间事件: serverCron 会一直定期执行,直到服务器关闭为止。
比如Redis-3.0.0中的hz默认值是10,代表每秒钟调用10次后台任务。
典型的方式为,Redis每秒做10次如下的步骤:
•随机测试100个设置了过期时间的key
•删除所有发现的已过期的key
•若删除的key超过25个则重复步骤1
总结:redis会在hz的频率下(n次每秒),会在一定时间限制内尽可能多的删除过期key。
对比发现,redis中key的总量为286957,比数据库中的264032高出了20000多个!为什么会这样呢?查找程序原因,并没有发现逻辑问题。查找redis相关资料,发现原来是redis对过期键处理机制导致的误差。 dbsize返回的是包含过期键的总数,所以造成了误差!结合查找的资料,拿来一起分享。
Redis对于过期键有三种清除策略:
被动删除:当读/写一个已经过期的key时,会触发惰性删除策略,直接删除掉这个过期key
主动删除:由于惰性删除策略无法保证冷数据被及时删掉,所以Redis会定期主动淘汰一批已过期的key
当前已用内存超过maxmemory限定时,触发主动清理策略
被动删除
只有key被操作时(如GET),REDIS才会被动检查该key是否过期,如果过期则删除之并且返回NIL。 1、这种删除策略对CPU是友好的,删除操作只有在不得不的情况下才会进行,不会对其他的expire key上浪费无谓的CPU时间。 2、但是这种策略对内存不友好,一个key已经过期,但是在它被操作之前不会被删除,仍然占据内存空间。如果有大量的过期键存在但是又很少被访问到,那会造成大量的内存空间浪费。expireIfNeeded(redisDb *db, robj *key)函数位于src/db.c。 但仅是这样是不够的,因为可能存在一些key永远不会被再次访问到,这些设置了过期时间的key也是需要在过期后被删除的,我们甚至可以将这种情况看作是一种内存泄露—-无用的垃圾数据占用了大量的内存,而服务器却不会自己去释放它们,这对于运行状态非常依赖于内存的Redis服务器来说,肯定不是一个好消息。
主动删除
先说一下时间事件,对于持续运行的服务器来说, 服务器需要定期对自身的资源和状态进行必要的检查和整理, 从而让服务器维持在一个健康稳定的状态, 这类操作被统称为常规操作(cron job)
在 Redis 中, 常规操作由 redis.c/serverCron 实现, 它主要执行以下操作
更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等。
清理数据库中的过期键值对。
对不合理的数据库进行大小调整。
关闭和清理连接失效的客户端。
尝试进行 AOF 或 RDB 持久化操作。
如果服务器是主节点的话,对附属节点进行定期同步。
如果处于集群模式的话,对集群进行定期同步和连接测试。
Redis 将 serverCron 作为时间事件来运行, 从而确保它每隔一段时间就会自动运行一次, 又因为 serverCron 需要在 Redis 服务器运行期间一直定期运行, 所以它是一个循环时间事件: serverCron 会一直定期执行,直到服务器关闭为止。
在 Redis 2.6 版本中, 程序规定 serverCron 每秒运行 10 次, 平均每 100 毫秒运行一次。 从 Redis 2.8 开始, 用户可以通过修改 hz选项来调整 serverCron 的每秒执行次数, 具体信息请参考 redis.conf 文件中关于 hz 选项的说明也叫定时删除,这里的“定期”指的是Redis定期触发的清理策略,由位于src/redis.c的activeExpireCycle(void)函数来完成。
serverCron是由redis的事件框架驱动的定位任务,这个定时任务中会调用activeExpireCycle函数,针对每个db在限制的时间REDIS_EXPIRELOOKUPS_TIME_LIMIT内迟可能多的删除过期key,之所以要限制时间是为了防止过长时间 的阻塞影响redis的正常运行。这种主动删除策略弥补了被动删除策略在内存上的不友好。
因此,Redis会周期性的随机测试一批设置了过期时间的key并进行处理。测试到的已过期的key将被删除。典型的方式为,Redis每秒做10次如下的步骤:
随机测试100个设置了过期时间的key
删除所有发现的已过期的key
若删除的key超过25个则重复步骤1
这是一个基于概率的简单算法,基本的假设是抽出的样本能够代表整个key空间,redis持续清理过期的数据直至将要过期的key的百分比降到了25%以下。这也意味着在任何给定的时刻已经过期但仍占据着内存空间的key的量最多为每秒的写操作量除以4.
Redis-3.0.0中的默认值是10,代表每秒钟调用10次后台任务。
除了主动淘汰的频率外,Redis对每次淘汰任务执行的最大时长也有一个限定,这样保证了每次主动淘汰不会过多阻塞应用请求,以下是这个限定计算公式:
#define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /* CPU max % for keys collection /
…
timelimit = 1000000ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
hz调大将会提高Redis主动淘汰的频率,如果你的Redis存储中包含很多冷数据占用内存过大的话,可以考虑将这个值调大,但Redis作者建议这个值不要超过100。我们实际线上将这个值调大到100,观察到CPU会增加2%左右,但对冷数据的内存释放速度确实有明显的提高(通过观察keyspace个数和used_memory大小)。
可以看出timelimit和server.hz是一个倒数的关系,也就是说hz配置越大,timelimit就越小。换句话说是每秒钟期望的主动淘汰频率越高,则每次淘汰最长占用时间就越短。这里每秒钟的最长淘汰占用时间是固定的250ms(1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/100),而淘汰频率和每次淘汰的最长时间是通过hz参数控制的。
从以上的分析看,当redis中的过期key比率没有超过25%之前,提高hz可以明显提高扫描key的最小个数。假设hz为10,则一秒内最少扫描200个key(一秒调用10次*每次最少随机取出20个key),如果hz改为100,则一秒内最少扫描2000个key;另一方面,如果过期key比率超过25%,则扫描key的个数无上限,但是cpu时间每秒钟最多占用250ms。
当REDIS运行在主从模式时,只有主结点才会执行上述这两种过期删除策略,然后把删除操作”del key”同步到从结点。
maxmemory 当前已用内存超过maxmemory限定时,触发主动清理策略
volatile-lru:只对设置了过期时间的key进行LRU(默认值)
allkeys-lru : 删除lru算法的key
volatile-random:随机删除即将过期key
allkeys-random:随机删除
volatile-ttl : 删除即将过期的
noeviction : 永不过期,返回错误
当mem_used内存已经超过maxmemory的设定,对于所有的读写请求,都会触发redis.c/freeMemoryIfNeeded(void)函数以清理超出的内存。注意这个清理过程是阻塞的,直到清理出足够的内存空间。所以如果在达到maxmemory并且调用方还在不断写入的情况下,可能会反复触发主动清理策略,导致请求会有一定的延迟。
清理时会根据用户配置的maxmemory-policy来做适当的清理(一般是LRU或TTL),这里的LRU或TTL策略并不是针对redis的所有key,而是以配置文件中的maxmemory-samples个key作为样本池进行抽样清理。
maxmemory-samples在redis-3.0.0中的默认配置为5,如果增加,会提高LRU或TTL的精准度,redis作者测试的结果是当这个配置为10时已经非常接近全量LRU的精准度了,并且增加maxmemory-samples会导致在主动清理时消耗更多的CPU时间,建议:
尽量不要触发maxmemory,最好在mem_used内存占用达到maxmemory的一定比例后,需要考虑调大hz以加快淘汰,或者进行集群扩容。
如果能够控制住内存,则可以不用修改maxmemory-samples配置;如果Redis本身就作为LRU cache服务(这种服务一般长时间处于maxmemory状态,由Redis自动做LRU淘汰),可以适当调大maxmemory-samples。
这里提一句,实际上redis根本就不会准确的将整个数据库中最久未被使用的键删除,而是每次从数据库中随机取5个键并删除这5个键里最久未被使用的键。上面提到的所有的随机的操作实际上都是这样的,这个5可以用过redis的配置文件中的maxmemeory-samples参数配置。
Replication link和AOF文件中的过期处理
为了获得正确的行为而不至于导致一致性问题,当一个key过期时DEL操作将被记录在AOF文件并传递到所有相关的slave。也即过期删除操作统一在master实例中进行并向下传递,而不是各salve各自掌控。这样一来便不会出现数据不一致的情形。当slave连接到master后并不能立即清理已过期的key(需要等待由master传递过来的DEL操作),slave仍需对数据集中的过期状态进行管理维护以便于在slave被提升为master会能像master一样独立的进行过期处理。
参考资料
https://github.com/redis/redis-doc
https://redis.io/topics
https://redis.io/commands
http://oldblog.antirez.com/post/redis-persistence-demystified.html