redis总结

303 阅读16分钟

redis基础

redis是一个非关系型数据库(NoSQL = Not Only SQL ,即“不仅仅是SQL”)。 NoSQL不依赖业务逻辑方式存储,而以简单的key-value模式存储,大大的增加了数据库的扩展能力。

  • 支持多数据类型,支持持久化,单线程+多路IO复用
  • 默认16个数据库,类似数组下标从0开始,初始默认使用0号库
  • 默认端口:6379
  • 使用命令 select dbid 来切换数据库(如: select 8 )
  • 统一密码管理,所有库同样密码

1、常用数据类型

1)、字符串(String)

  • key-value类型
  • 二进制安全的:意味着Redis的string可以包含任何数据(比如jpg图片或者序列化的对象)
  • 字符串value最多可以是512M

数据结构:简单动态字符串(Simple Dynamic String,缩写SDS)

  • 扩容:字符串长度小于1M时,加倍现有的空间;超过1M,一次只会多扩1M的空间
  • 字符串最大长度为512M

2)、列表(List)

  • 单键多值
  • 简单的字符串列表,按照插入顺序排序。(可以添加一个元素到列表的头部(左边)或者尾部(右边))
  • 底层实际是个双向链表,对两端的操作性能很高,通过索引下标的操作中间的节点性能会较差

数据结构:快速链表(quickList)

image.png Redis将多个ziplist(压缩列表:将所有的元素紧挨着一起存储,分配的是一块连续的内存)使用双向指针(结构上还需要两个额外的指针prev和next)串起来使用,既满足了快速的插入删除性能,又不会出现太大的空间冗余

3)、集合(Set)

  • string类型的无序集合
  • 底层是一个value为null的hash表,所以添加,删除,查找的复杂度都是O(1)

数据结构:dict字典

  • 字典是用哈希表实现的
  • Set内部也是使用hash结构,所有的value都指向同一个内部值

4)、哈希(Hash)

  • 键值对集合
  • string类型的field和value的映射表,适合用于存储对象(类似Java的Map<String,Object>)

数据结构

  • 两种:ziplist(压缩列表),hashtable(哈希表)
  • 当field-value长度较短且个数较少时,使用ziplist,否则使用hashtable

5)、有序集合Zset(sorted set)

  • 同set,且每个成员都关联了一个评分(score),这个评分(score)被用来按从最低分到最高分的方式排序
  • 集合的成员是唯一的,但是评分可以是重复了

数据结构

  • hash:关联元素value和权重score,保障元素value的唯一性,可以通过元素value找到相应的score值。
  • 跳跃表:给元素value排序,根据score的范围获取元素列表

跳跃表实例:有序链表和跳跃表,从链表中查询出51

  • (1)有序链表 image.png

从第一个元素开始依次查找、比较才能找到(共需要6次比较)。

  • (2)跳跃表 image.png
  • 从第2层开始,1节点比51节点小,向后比较。
  • 21节点比51节点小,继续向后比较,后面就是NULL了,所以从21节点向下到第1层
  • 在第1层,41节点比51节点小,继续向后,61节点比51节点大,所以从41向下
  • 在第0层,51节点为要查找的节点,节点被找到(共查找4次)。

2、新数据类型

1)、Bitmaps

image.png

  • (1)Bitmaps本身不是一种数据类型, 实际上就是字符串(key-value), 但是它可以对字符串的位进行操作。
  • (2)可以把Bitmaps想象成一个以位为单位的数组, 数组的每个单元只能存储0和1, 数组的下标在Bitmaps中叫做偏移量(offset:偏移量从0开始)。

实例

用户是否访问过网站,访问的用户记做1,没有访问的用户记做0,用偏移量作为用户的id。 假设现在有20个用户,userid=1,6,11,15,19的用户对网站进行了访问 image.png

2)、HyperLogLog:用来做基数统计的算法

  • 基数问题:求集合中不重复元素个数的问题,像UV(UniqueVisitor,独立访客)、独立IP数、搜索记录数等需要去重和计数的问题。
  • 优点:在输入元素的数量或体积非常非常大时,计算基数所需的空间总是固定、且很小(12 KB)。
  • 缺点会降低一定精度

3)、Geospatial:经纬度操作

Redis 3.2 中增加了对GEO(Geographic:地理信息的缩写)类型的支持,就是元素的2维坐标,在地图上就是经纬度。redis基于该类型,提供了经纬度设置,查询,范围查询,距离查询,经纬度Hash等常见操作。

3、maxmemory-policy(过期策略)

  • volatile-lru:使用LRU算法移除key,只对设置了过期时间的键;(最近最少使用)
  • allkeys-lru:在所有集合key中,使用LRU算法移除key
  • volatile-random:在过期集合中移除随机的key,只对设置了过期时间的键
  • allkeys-random:在所有集合key中,移除随机的key
  • volatile-ttl:移除那些TTL值最小的key,即那些最近要过期的key
  • noeviction:不进行移除。针对写操作,只是返回错误信息

redis 事务_锁机制

  • 单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
  • 主要作用:串联多个命令防止别的命令插队。

1、redis事物操作常用命令

  • Multi:命令开始(输入的命令依次进入命令队列中,但不会执行)
  • Exec(组队执行):将之前的命令队列中的命令依次执行
  • discard(放弃组队):命令队列中的命令不执行
  • watch key1 [key2]:监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断
  • unwatch:取消 WATCH 命令对所有 key 的监视(在执行 WATCH 命令之后,EXEC 命令或DISCARD 命令先被执行了的话,那么就不需要再执行UNWATCH 了) image.png

2、redis事务三特性

  • 单独的隔离操作 事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
  • 没有隔离级别的概念 队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行
  • 不保证原子性 事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚

3、redis事务的错误处理

1、Exec之前:组队中某个命令出现了报告错误,执行时整个的所有队列都会被取消 image.png eg: 企业微信截图_a15136cf-933a-4e41-8525-ead65c3977d8.png 2、执行阶段某个命令报出了错误,则只有报错的命令不会被执行,而其他的命令都会执行,不会回滚 image.png eg: 企业微信截图_7fb2d1bf-4076-4916-97fc-a03893585ea6.png

4、事务冲突问题:乐观锁(check-and-set机制)

  • 乐观锁(Optimistic Lock):每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。
  • 适用于多读的应用类型:可以提高吞吐量。
  • Redis事物:利用check-and-set机制实现事务的

5、秒杀案例

  • 连接超时问题:使用连接池
  • 库存超卖问题:使用乐观锁(可能会有库存遗留问题)
  • 库存遗留问题(库存有剩余):使用lua脚本。一次提交给redis执行,减少反复连接redis,提升性能

代码实现例子

/**
* lua脚本(一次提交给redis执行):类似redis事务,有一定的原子性,不会被其他命令插队
**/
static String secKillScript ="local userid=KEYS[1];\r\n" + 
                "local prodid=KEYS[2];\r\n" + 
                "local qtkey=\"库存key:\" + productId;\r\n" + 
                "local usersKey=\"用户key:\" + productId;\r\n" + 
                "local userExists=redis.call(\"sismember\",usersKey,userid);\r\n" + 
                "if tonumber(userExists)==1 then \r\n" + 
                "   return 2;\r\n" + 
                "end\r\n" + 
                "local num= redis.call(\"get\" ,qtkey);\r\n" + 
                "if tonumber(num)<=0 then \r\n" + 
                "   return 0;\r\n" + 
                "else \r\n" + 
                "   redis.call(\"decr\",qtkey);\r\n" + 
                "   redis.call(\"sadd\",usersKey,userid);\r\n" + 
                "end\r\n" + 
                "return 1" ;
/**
* 秒杀过程
* @param uid 用户ID:一个用户只能秒杀一次
* @param prodid 商品ID
**/
public static boolean doSecKill(String uid,String prodid) throws IOException {
        // 1、通过连接池得到jedis对象:解决连接超时问题
        JedisPool jedisPoolInstance = JedisPoolUtil.getJedisPoolInstance();
        Jedis jedis = jedisPoolInstance.getResource();

        // 【方式1】2、乐观锁:解决超卖问题
        String kcKey = "库存key:" + productId;
        String userKey = "用户key:" + productId;
        jedis.watch(kcKey); // 监视库存
        if(jedis.get(kcKey) <= 0 || jedis.sismember("用户key:"+productId, uid)) {
                System.out.println("秒杀失败了....");
                jedis.close();
                return false;
        }
        Transaction multi = jedis.multi();// 开始:使用事务
        multi.decr(kcKey);// 组队操作1
        multi.sadd(userKey,uid);// 组队操作2
        List<Object> results = multi.exec();// 执行事务
        if(results == null || results.size() == 0) {
                System.out.println("秒杀失败了....");
                jedis.close();
                return false;
        }

        // 【方式2】3、lua方式:解决库存遗留问题
        String sha1=  jedis.scriptLoad(secKillScript);
        Object result= jedis.evalsha(sha1, 2, uid,prodid);
        String reString=String.valueOf(result);
        if ("0".equals( reString )  ) {
                System.err.println("已抢空!!");
        }else if("1".equals( reString )  )  {
                System.out.println("抢购成功!!!!");
        }else if("2".equals( reString )  )  {
                System.err.println("该用户已抢过!!");
        }else{
                System.err.println("抢购异常!!");
        }

        System.out.println("秒杀成功了..");
        jedis.close();
        return true;
}

redis持久化

  • RDB(Redis DataBase):指定的时间间隔内将内存中的数据集快照(Snapshot)写入磁盘,恢复时是将快照文件直接读到内存里
  • AOF(Append Of File):以日志的形式来记录每个写操作(增量保存:读操作不记),只追加文件但不可改写,redis启动之初会读取该文件重新构建数据(顺序执行一次所有命令)

1、RDB(Redis DataBase)

image.png

Redis会单独创建(fork)一个子进程来进行持久化,会先将数据写入到 一个临时文件中,待持久化过程都结束了,再用这个临时文件替换上次持久化好的文件(写时复制技术)

  • 默认:为dump.rdb
  • 缺点:最后一次持久化后的数据可能丢失 企业微信截图_06b90dca-3942-4a0c-b1db-c511aeeac057.png

rdb的恢复

  • 关闭Redis
  • 先把备份的文件拷贝到工作目录下 cp dump2.rdb dump.rdb
  • 启动Redis, 备份数据会直接加载

优势

  • 适合大规模的数据恢复
  • 对数据完整性和一致性要求不高更适合使用
  • 节省磁盘空间
  • 恢复速度快

劣势

  • Fork的时候,内存中的数据被克隆了一份,大致2倍的膨胀性需要考虑
  • 虽然Redis在fork时使用了写时拷贝技术,但是如果数据庞大时还是比较消耗性能。
  • 在备份周期在一定间隔时间做一次备份,所以如果Redis意外down掉的话,就会丢失最后一次快照后的所有修改

2、AOF(Append Of File)

企业微信截图_36dc3e1e-fdf4-473b-8ac5-0b6872703477.png

  • 客户端的请求写命令会被append追加到AOF缓冲区内;
  • AOF缓冲区根据AOF持久化策略[always,everysec,no]将操作sync同步到磁盘的AOF文件中;
  • AOF文件大小超过重写策略或手动重写时,会对AOF文件rewrite重写(记录最终指令),压缩AOF文件容量(文件是上次rewrite后大小的一倍且大于64M时触发);
  • Redis服务重启时,会重新load加载AOF文件中的写操作达到数据恢复的目的

异常恢复

  • 修改默认的appendonly no,改为yes
  • (异常恢复有此步骤)如遇到AOF文件损坏,通过/usr/local/bin/redis-check-aof--fix appendonly.aof进行恢复
  • 备份被写坏的AOF文件
  • 恢复:重启redis,然后重新加载

同步频率设置

  • appendfsync always 始终同步,每次Redis的写入都会立刻记入日志;性能较差但数据完整性比较好
  • appendfsync everysec 每秒同步,每秒记入日志一次,如果宕机,本秒的数据可能丢失。
  • appendfsync no redis不主动进行同步,把同步时机交给操作系统

优势

  • 备份机制更稳健,丢失数据概率更低。
  • 可读的日志文本,通过操作AOF稳健,可以处理误操作。

劣势

  • 比起RDB占用更多的磁盘空间。
  • 恢复备份速度要慢。
  • 每次读写都同步的话,有一定的性能压力。
  • 存在个别Bug,造成不能恢复不能。

3、总结(Which one)

  • 官方推荐:两个都启用(当redis重启的时优先载入AOF文件来恢复)
  • 如果对数据不敏感:可以选单独用RDB(不建议单独用 AOF,因为可能会出现Bug和性能问题)
  • 如果只是做纯内存缓存:可以都不用
  • 建议:只在Slave上持久化RDB文件

redis高阶

1、redis主从复制(一主多从)

master/slaver机制:Master以写为主,Slave以读为主

  • 读写分离,性能扩展
  • 容灾快速恢复 image.png

常用方式

  • 一主二仆:至少2个从
  • 薪火相传:上一个Slave可以是下一个slave的Master(可以有效减轻master的写压力,去中心化降低风险)
  • 反客为主:当一个master宕机后,后面的slave可以立刻升为master,其后面的slave不用做任何修改(手动:用 slaveof no one 将从机变为主机)

2、redis哨兵模式(sentinel)

  • 反客为主的自动版:能够后台监控主机是否故障,如果故障了根据投票数自动将从库转换为主库 image.png

主要命令

  • 配置哨兵 ~ % sentinel monitor mymaster 127.0.0.1 6379 1

其中mymaster为监控对象起的服务器名称, 1 为至少有多少个哨兵同意迁移的数量。

  • 启动哨兵 ~ % redis-sentinel /myredis/sentinel.conf

问题:复制延时

由于所有的写操作都是先在Master上操作,然后同步更新到Slave上,所以从Master同步到Slave机器有一定的延迟,当系统很繁忙的时候,延迟问题会更加严重,Slave机器数量的增加也会使这个问题更加严重

故障恢复

  • 优先级在redis.conf中默认:slave-priority 100,值越小优先级越高
  • 偏移量是指获得原主机数据最全的
  • 每个redis实例启动后都会随机生成一个40位的runid image.png

3、redis集群(无中心化)

无中心化:无论从哪台主机写的数据,其他主机上都能读到数据 image.png

  • Redis 集群实现了对Redis的水平扩容,即启动N个redis节点,将整个数据库分布存储在这N个节点中,每个节点存储总数据的1/N。
  • Redis 集群通过分区(partition)来提供一定程度的可用性(availability): 即使集群中有一部分节点失效或者无法进行通讯, 集群也可以继续处理命令请求。

redis cluster分配节点方式

  • 一个集群至少要有三个主节点。
  • 选项 --cluster-replicas 1 表示我们希望为集群中的每个主节点创建一个从节点。
  • 分配原则尽量保证每个主数据库运行在不同的IP地址,每个从库和主库不在一个IP地址上
  • 一个 Redis 集群包含 16384 个插槽(hash slot), 数据库中的每个键都属于这 16384 个插槽的其中一个( CRC16(key) % 16384 :计算键 key 属于的插槽)

故障恢复

  • 如果主节点下线,从节点能否自动升为主节点(注意:15秒超时)
  • 主节点恢复后:主节点回来变成从机
  • 如果某一段插槽的主从都挂掉:cluster-require-full-coverage=yes ,整个集群都挂掉;cluster-require-full-coverage=no ,该插槽数据全都不能使用,也无法存储

优点

  • 实现扩容
  • 分摊压力
  • 无中心配置相对简单

缺点

  • 多键操作是不被支持的
  • 多键的Redis事务是不被支持的。lua脚本不被支持
  • 由于集群方案出现较晚,很多公司已经采用了其他的集群方案,而代理或者客户端分片的方案想要迁移至redis cluster,需要整体迁移而不是逐步过渡,复杂度较大。

4、Redis应用问题

1)、缓存穿透(不存在的数据)

问题描述

key对应的数据在数据源并不存在,每次针对此key的请求从缓存获取不到,请求都会压到数据源,从而可能压垮数据源

解决方案

  • (1)对空值缓存:(null)进行缓存,空结果的过期时间会很短(最长不超过五分钟)。
  • (2)设置可访问的名单(白名单): 使用bitmaps类型定义一个可以访问的名单,名单id作为偏移量,如果访问id不在bitmaps里面,进行拦截,不允许访问。
  • (3)采用布隆过滤器: 将所有可能存在的数据哈希到一个足够大的bitmaps中,一个一定不存在的数据会被这个bitmaps拦截掉,从而避免了对底层存储系统的查询压力。
  • (4)进行实时监控: 当发现Redis的命中率开始急速降低,需要排查访问对象和访问的数据,和运维人员配合,可以设置黑名单限制服务

2)、缓存击穿(热点数据:单个)

问题描述

某个key过期,大量请求直接访问数据库

解决方案

  • (1)预先设置热门数据:在redis高峰访问之前,把一些热门数据提前存入到redis里面,加大这些热门数据key的时长
  • (2)实时调整:现场监控哪些数据热门,实时调整key的过期时长
  • (3)使用锁:在缓存失效的时候(判断拿出来的值为空),不是立即去load db。先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX) image.png

3)、缓存雪崩(多个数据同时过期)

问题描述

极短时间内很多key过期,大量请求直接访问数据库

解决方案

  • (1)构建多级缓存架构:nginx缓存 + redis缓存 +其他缓存(ehcache等)
  • (2)使用锁或队列: 用加锁或者队列的方式保证不会有大量的线程对数据库一次性进行读写(不适用高并发情况)
  • (3)设置过期标志更新缓存: 记录缓存数据是否过期(设置提前量),后台提前去更新实际key的缓存。
  • (4)将缓存失效时间分散开

4)、分布式锁实现问题

分布式锁满足条件

  • 互斥性:在任意时刻,只有一个客户端能持有锁。
  • 不会发生死锁:即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  • 解铃还须系铃人:加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
  • 加锁和解锁必须具有原子性。

常见问题

  • 无法释放锁:上锁时同时设置过期时间(SET key value EX second)
  • 释放了错误锁:setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁
  • 删除操作缺乏原子性:使用LUA脚本保证删除的原子性了(比较uuid,删除:同时操作)

代码实现例子

// 1. 上锁时同时设置过期时间(同时指定uuid区别锁):set k1 v1 px 20000 nx
String uuid = UUID.randomUUID().toString();
Boolean lock = this.redisTemplate.opsForValue()
      .setIfAbsent("lock", uuid, 2, TimeUnit.SECONDS);
      
// 2. 释放锁:原子操作(比较uuid,删除:同时操作)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// 设置lua脚本返回的数据类型
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
// 设置lua脚本返回类型为Long
redisScript.setResultType(Long.class);
redisScript.setScriptText(script);
redisTemplate.execute(redisScript, Arrays.asList("lock"), uuid);

参考资料

官方文档