编码转换
当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码,对于使用ziplist编码的有序集合对象来说,当使用ziplist编码所需的两个条件中的任意一个不能被满足时,就会执行对象的编码转换操作,原本保存在压缩列表里的所有集合元素都会被转移并保存到zset结构里面,对象的编码也会从ziplist变为skiplist
- 有序集合保存的元素数量小于128个
- 有序集合保存的所有元素成员的长度都小于64字节
不能满足以上两个条件的有序集合对象将使用skiplist编码,以上两个条件的上限值是可以修改的,以下是一些命令相关操作:
数据对象的特性
作为缓存的常用中间件,Redis的数据对象具备如下的特性来支持它更好的服务于缓存。
类型检查与多态
Redis中用于操作键的命令基本上可以分为两种类型。一种是通用类的指令,一种是针对某个特定的数据对象类型服务的:
- 通用类命令:DEL命令、EXPIRE命令、RENAME命令、TYPE命令、OBJECT命令等
- 特定类型命令:SET、GET、APPEND、STRLEN等命令只能对字符串键执行;HDEL、HSET、HGET、HLEN等命令只能对哈希键执行;RPUSH、LPOP、LINSERT、LLEN等命令只能对列表键执行;SADD、SPOP、SINTER、SCARD等命令只能对集合键执行;ZADD、ZCARD、ZRANK、ZSCORE等命令只能对有序集合键执行
对于特定类型命令,如果操作的指令和类型对不上,则会报类型错误,这就是类型检查,为了确保只有指定类型的键可以执行某些特定的命令,在执行一个类型特定的命令之前,Redis会先检查输入键的类型是否正确,然后再决定是否执行给定的命令,类型检查流程如下:
- 在执行一个类型特定命令之前,服务器会先检查输入数据库键的值对象是否为执行命令所需的类型,如果是的话,服务器就对键执行指定的命令
- 否则,服务器将拒绝执行命令,并向客户端返回一个类型错误
Redis除了会根据值对象的类型来判断键是否能够执行指定命令之外,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令,如果对一个list键执行LLEN命令,那么服务器除了要确保执行命令的是列表键之外,还需要根据键的值对象所使用的编码来选择正确的LLEN命令实现,多态的实现流程如下:
- 如果列表对象的编码为ziplist,那么说明列表对象的实现为压缩列表,程序将使用ziplistLen函数来返回列表的长度
- 如果列表对象的编码为linkedlist,那么说明列表对象的实现为双端链表,程序将使用listLength函数来返回双端链表的长度
LLEN命令是多态(polymorphism)的,只要执行LLEN命令的是列表键,那么无论值对象使用的是ziplist编码还是linkedlist编码,命令都可以正常执行
内存回收
因为Redis是基于C语言的嘛,而C语言并不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个引用计数(reference counting)【和Java的引用计数机制是一样的】技术实现内存回收机制。通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。每个对象的引用计数信息由redisObject结构的refcount属性记录。
- 在创建一个新对象时,引用计数的值会被初始化为1
- 当对象被一个新程序使用时,它的引用计数值会被增一
- 当对象不再被一个程序使用时,它的引用计数值会被减一
- 当对象的引用计数值变为0时,对象所占用的内存会被释放
由于Redis是个内存级的数据库,所以可想而知其瓶颈就在内存上,内存回收策略很重要,而且Java其实也是基于C实现的。
对象共享
除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。假设键A创建了一个包含整数值100的字符串对象作为值对象,键B也要创建一个同样保存了整数值100的字符串对象作为值对象,此时B发现A已经创建了,则无需再创建而是直接指向A的值对象即可。
共享对象池
Redis会在初始化服务器时,创建一万个字符串对象,这些对象包含了从0到9999的所有整数值,当服务器需要用到值为0到9999的字符串对象时,服务器就会使用这些共享对象,而不是新创建。这一万个字符串对象也叫共享对象池。创建共享字符串对象的数量可以通过修改配置来调整。
- 这些共享对象的引用计数开始都是1,被服务器引用,之后如果有键A或B之类的指向它,refcount就累加即可,但不会被释放,除非服务器宕机,重新初始化。
- 这些共享对象不单单只有字符串键可以使用,那些在数据结构中嵌套了字符串对象的对象(linkedlist编码的列表对象、hashtable编码的哈希对象、hashtable编码的集合对象,以及zset编码的有序集合对象)都可以使用这些共享对象
共享对象池对于节约内存还是很重要的
为什么Redis不共享包含字符串的对象
当服务器考虑将一个共享对象设置为键的值对象时,程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同,只有在共享对象和目标对象完全相同的情况下,程序才会将共享对象用作键的值对象,而一个共享对象保存的值越复杂,验证共享对象和目标对象是否相同所需的复杂度就会越高,消耗的CPU时间也会越多
- 如果共享对象是保存整数值的字符串对象,那么验证操作的复杂度为O(1)
- 如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为O(N)
- 如果共享对象是包含了多个值(或者对象的)对象,比如列表对象或者哈希对象,那么验证操作的复杂度将会是O(N 2)
尽管共享更复杂的对象可以节约更多的内存,但受到CPU时间的限制,Redis只对包含整数值的字符串对象进行共享。所以是基于内存和CPU的平衡来考虑的吧!
对象的空转时长
除了介绍过的type、encoding、ptr和refcount四个属性之外,redisObject结构包含的最后一个属性为lru属性,该属性记录了对象最后一次被命令程序访问的时间:
- OBJECT IDLETIME命令可以打印出给定键的空转时长,这一空转时长就是通过将当前时间减去键的值对象的lru时间计算得出的,所以数值越小越好**,越小说明键越热点**。
- OBJECT IDLETIME命令的实现是特殊的,这个命令在访问键的值对象时,不会修改值对象的lru属性
如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存数超过了maxmemory选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存
Jedis的使用
Jedis实际上就是Java语言操作Redis数据的工具,其实我们之前在用JDBC操作Mysql的时候也是一样的,实际上Redis不也是一个非关系型的数据库嘛!
Jedis有如下的一些优点:轻量,简洁,便于集成和改造;支持连接池;支持pipelining、事务、LUA Scripting、Redis Sentinel、Redis Cluster,但是需要注意,它不支持读写分离,需要自己实现,还是我们之前看的按次计时服务案例:
综合分析如下实现步骤:
实现代码如下:
import redis.clients.jedis.Jedis; import redis.clients.jedis.exceptions.JedisException; //业务服务和管理 public class JedisService { private String id; private int num; public JedisService(String id,int num){ this.id=id; this.num=num; } public void servie(){ //连接Redis Jedis jedis=new Jedis("127.0.0.1",6379); String value= jedis.get("compid"+id); try { if(value==null){ jedis.setex("compid"+id,5,Long.MAX_VALUE-num +""); }else { Long val=jedis.incr("compid"+id); business(id,num-(Long.MAX_VALUE-val)); } } catch (JedisException e){ System.out.println("使用已达上限"); return ; }finally { //关闭Redis jedis.close(); } } public void business(String id,Long val){ System.out.println("用户"+id+"执行业务操作第"+val+"次"); } } //线程管理服务 class MyThread extends Thread{ JedisService jedisService; MyThread(String id,int num){ jedisService =new JedisService(id,num); } public void run(){ while (true){ jedisService.servie(); try { Thread.sleep(300L); } catch (InterruptedException e) { e.printStackTrace(); } } } } class main{ public static void main(String[] args) { MyThread myThread=new MyThread("初级用户",10); MyThread myThread1=new MyThread("高级用户",30); myThread.start(); myThread1.start(); } }
Redis持久化策略
我们知道Redis时一个内存级的数据库,如果内存中的数据如果突然遭遇断电,将会丢失Redis保存的数据,那么为了保证数据不丢失,内存中的数据要持久化到硬盘里来,利用永久性存储介质将数据进行保存,在特定的时间将保存的数据进行恢复的工作机制称为持久化。持久化的作用就是防止数据的意外丢失,确保数据安全性!也就是为什么我们每写会儿文档就要保存一次的原因
RDB持久化策略
RDB是数据快照的持久化策略,只存储数据结果,存储格式简单,关注点在数据。依据执行持久化时机分为如下三种策略
save即时执行策略
save即使生成策略持久化的命令为save,会阻塞输入的指令,save指令比较耗费服务器性能!
bgsave后台执行策略
使用指令bgsave会使用延迟执行save策略。
条件save后台执行策略
限定时间限定条件的save持久化:满足限定时间内key的变化数量达到指定数量则进行持久化。命令为:
save second changes
second代表指定时间,changes代表key的变化数量,如果设置为:
save 100 10
100秒内有10个key变化则进行持久化,那么如果我在100秒到期的时候只有9个key变化,则重置时间,重新从上一次持久化后的key的变化数全量统计变化值【实际上也就是9个】,也就是剩余100秒只需再等待一个key变化就能进行持久化了。
RDB三种执行策略对比
以下是三种执行RDB方式的对比:
方式 | save | bgsave | 条件save |
读写 | 同步 | 异步 | 满足条件异步 |
阻塞客户端指令 | 是 | 否 | 否 |
额外内存 | 否 | 是 | 是 |
启动新进程 | 否 | 是 | 是 |
推荐使用度 | 低 | 中 | 高 |
RDB策略的优缺点
优点
- RDB存储效率高【能存更多】,RDB文件是紧凑的二进制文件,存储效率高,比较适合做冷备,灾备,全量复制的场景。RDB做会生成多个文件,每个文件都代表了某一个时刻的Redis完整的数据快照,RDB这种多个数据文件的方式,非常适合做冷备,因为大量的一个个的文件,可以每隔一定的时间,复制出来;可以将这种完整的数据文件发送到一些远程的云服务、分布式存储上进行安全的存储,以预定好的备份策略来定期备份Redis中的数据;
- RDB恢复数据更快【能恢复更快】,直接基于RDB数据文件来重启和恢复Redis进程,更加快速:RDB就是一份数据文件,恢复的时候,直接加载到内存中即可;
- **RDB对Redis的读写无影响,RDB对Redis【不影响Redis】对外提供的读写服务,影响非常小,可以让Redis保持高性能,因为Redis主进程只需要fork一个子进程,让子进程执行磁盘IO操作来进行RDB持久化即可;RDB每次写,都是直接写Redis内存,只是在一定的时候,才会将数据写入磁盘中
缺点
- RDB无法做到实时持久化【可能会丢数据】,一般来说,RDB数据快照文件,都是每隔5分钟,或者更长时间生成一次,这个时候就得接受一旦Redis进程宕机,那么会丢失最近5分钟的数据;这个问题,也是RDB最大的缺点,就是不适合做第一优先的恢复方案,如果你依赖RDB做第一优先恢复方案,会导致数据丢失的比较多;
- RDB在fork子进程时消耗内存【有一些内存损耗】,RDB每次在fork子进程来执行RDB快照数据文件生成的时候,都会牺牲一些内存。
- RDB基于快照,每次读写都是全量数据,数据量大时性能较低
- RDB如果设置的dump读写时间不合适,大数据量下会有IO频繁的风险
AOF持久化策略
AOF是数据快照的持久化策略,存储操作过程,存储格式复杂,关注点在数据的操作过程。我们依据RDB的缺点就能理解AOF的存在价值了,因为没有哪种策略是完美的,只有合适的:
- RDB无法做到实时持久化【可能会丢数据】,一般来说,RDB数据快照文件,都是每隔5分钟,或者更长时间生成一次,这个时候就得接受一旦Redis进程宕机,那么会丢失最近5分钟的数据;这个问题,也是RDB最大的缺点,就是不适合做第一优先的恢复方案,如果你依赖RDB做第一优先恢复方案,会导致数据丢失的比较多;
- RDB在fork子进程时消耗内存【有一些内存损耗】,RDB每次在fork子进程来执行RDB快照数据文件生成的时候,都会牺牲一些内存。
- RDB基于快照,每次读写都是全量数据,数据量大时性能较低
- RDB如果设置的dump读写时间不合适,大数据量下会有IO频繁的风险
基于以上问题,我们看下AOF的实现。
- 只记录部分数据,不记录全量数据
- 只记录操作过程,不记录操作数据
- 对所有操作均记录,降低丢失数据的可能性。
AOF也有三种策略:
always写数据策略
Redis 在每个事件循环都要将 AOF 缓冲区中的所有内容写入到 AOF 文件,并且同步 AOF 文件,所以 always 的效率是 appendfsync 选项三个值当中最差的一个,但从安全性来说,也是最安全的。当发生故障停机时,AOF 持久化也只会丢失一个事件循环中所产生的命令数据。数据零误差,性能极低,不推荐使用
everysec写数据策略
Redis 在每个事件循环都要将 AOF 缓冲区中的所有内容写入到 AOF 文件中,并且每隔一秒就要在子线程中对 AOF 文件进行一次同步。从效率上看,该模式足够快。当发生故障停机时,只会丢失一秒钟的命令数据。准确性较高,性能较高,推荐使用
no写数据策略
Redis 在每一个事件循环都要将 AOF 缓冲区中的所有内容写入到 AOF 文件。而 AOF 文件的同步由操作系统控制。这种模式下速度最快,但是同步的时间间隔较长,出现故障时可能会丢失较多数据
AOF重写机制
并不是所有的AOF数据都需要重写。
- 进程里超时的数据不再重写,例如进程里已经过期的一些数据就不再重写了。
- 忽略无效指令,重写时使用进程中的最终数据直接生成,这样AOF只保留最终数据生成命令。例如连续冗余的set。
- 对同一数据的多条指令进行合并,例如3次incr num,可以调整为:set num 3
满足这些条件就会触发重写,以降低AOF文件的内存占用。AOF执行的重写原理:
我们最常用的是everysec开启重写这种模式,我们详细看下这种模式:
RDB与AOF对比
学习完了两种持久化机制后,我们来看下两种持久化机制的对比:
持久化方式 | RDB | AOF |
占用存储空间 | 小(数据级压缩) | 大(指令级重写) |
存储速度 | 慢 | 快 |
恢复速度 | 快 | 慢 |
数据安全性 | 会丢失数据 | 依据策略而定,最多1秒 |
资源消耗 | 高 | 低 |
启动优先级 | 低 | 高 |
选择的时候可以依据如下策略,对数据敏感选择AOF【实时】,对数据不敏感选择RDB【阶段】
Redis的事务机制
其实和Mysql一样,Redis虽然作为一个非关系的K-V结构数据库,也是存在事务的,当然事务并不会有MySQL那么强。当多个客户端对同一个Key执行set操作的时候,客户端的get预期是会有偏差的,那么依赖于Redis的单线程特性,我们处理Redis的问题比Mysql的要简单一些。
Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令
事务的工作流程
事务有三个基本操作,创建事务队列multi;执行事务exec;取消事务discard。
执行的流程如下,当一条指令到来的时候要判断它是普通指令还是事务指令,
- 如果是普通指令,则判断当前是否存在事务队列,如果不存在直接执行,如果存在则入队
- 如果是事务指令,那么分为三种,multi为开启事务队列,exec为执行事务队列中的指令,执行完成后销毁队列,descrad为不执行队列中的指令,直接销毁队列
执行的时候按照先进先出的队列模式进行执行,事务操作时出错的情况分为两种:指令书写错误和语法性错误(例如让list实现自增),这种情况下处理机制是什么呢?
- 指令书写错误,若在事务队列中存在命令书写错误(类似于java编译性错误),则执行EXEC命令时,所有命令都不会执行
- 指令语法错误,若在事务队列中存在语法性错误(类似于java的1/0的运行时异常),则执行EXEC命令时,其他正确命令会被执行,错误命令抛出异常
通过上边两种异常的了解我们知道,当指令出现语法错误的时候,redis是不支持事务的回滚机制的。能做的只能通过持久化的备份去恢复,以及写代码的时候小心小心再小心。
锁的使用
想象一个场景,如果多个客户端都想对同一个Key进行操作,如果我们只使用事务去限制,不一定能达到效果。例如我想让一个num自增1,客户端1和客户端2都接到了这个任务**【商品补货】**:客户端1创建了一个事务,并且让num自增1
127.0.0.1:6379> flushdb OK 127.0.0.1:6379> set age 50 OK 127.0.0.1:6379> multi OK 127.0.0.1:6379> incr age QUEUED 127.0.0.1:6379> exec 1) (integer) 52 127.0.0.1:6379>
结果在客户端1事务队列执行的时候,客户端2对num进行了自增得到了51,此时客户端1再执行事务的时候发现结果变为了52,事务怎么不具备原子性了呢?这都是并发导致的问题
127.0.0.1:6379> incr age (integer) 51 127.0.0.1:6379>
Watch锁
为了解决这个问题,我们可以使用锁来监控key的状态,只要监控的key变化了,那么事务就取消执行,防止执行过程中的非原子性。还是上一个例子:客户端1开启锁并开启事务
127.0.0.1:6379> flushdb OK 127.0.0.1:6379> set age 50 OK 127.0.0.1:6379> watch age OK 127.0.0.1:6379> multi OK 127.0.0.1:6379> incr age QUEUED 127.0.0.1:6379> set name tml QUEUED 127.0.0.1:6379> exec (nil) 127.0.0.1:6379> get name (nil) 127.0.0.1:6379>
同时客户端2在事务执行的过程中incr了num:
127.0.0.1:6379> flushdb OK 127.0.0.1:6379> incr age (integer) 51 127.0.0.1:6379>
那么执行事务的时候就返回了nil,事务执行失败,并且这个操作相当于销毁队列,连name的值都取不到,还有一点需要注意,当watch事务的时候,即使事务队列没有watch的key,如果key发生变化也会销毁队列,redis不会识别队列的key:
127.0.0.1:6379> flushdb OK 127.0.0.1:6379> set age 50 OK 127.0.0.1:6379> watch age OK 127.0.0.1:6379> multi OK 127.0.0.1:6379> set name guochengyu QUEUED 127.0.0.1:6379> exec (nil) 127.0.0.1:6379> get name (nil) 127.0.0.1:6379>
例如这里,watch
的是age,但是我事务里对name操作,当另一个客户端对age操作变化后,事务队列name的操作也是无效的。如果我们不想监控的话,可以使用unwatch
命令,unwatch之后事务队列可以正常执行了