Redis实战

207 阅读13分钟

简介

Redis(Remote Dictionary Server ),即远程字典服务,是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。

使用场景

  1. Redis命令

www.runoob.com/redis/redis…

  1. string

    1. 字符缓存

作为KV型的内存数据库,Redis 最先会被想到的应用场景便是作为数据缓存。合理的利用缓存不仅能够提升网站访问速度,还能大大降低数据库的压力。Redis提供了键过期功能,也提供了灵活的键淘汰策略,所以Redis用在缓存的场合非常多。

  1. 计数器

如电商网站商品的浏览量、视频网站视频的播放数等。为了保证数据实时效,每次浏览都得给+1,并发量高时如果每次都请求数据库操作无疑是种挑战和压力。Redis提供的incr命令来实现计数器功能,内存操作,性能非常好,非常适用于这些计数场景。

  1. 分布式会话

集群模式下,在应用不多的情况下一般使用容器自带的session复制功能就能满足,当应用增多相对复杂的系统中,一般都会搭建以Redis等内存数据库为中心的session服务,session不再由容器管理,而是由session服务及内存数据库管理。

  1. 分布式锁

在很多互联网公司中都使用了分布式技术,分布式技术带来的技术挑战是对同一个资源的并发访问,如全局ID、减库存、秒杀等场景,并发量不大的场景可以使用数据库的悲观锁、乐观锁来实现,但在并发量高的场合中,利用数据库锁来控制资源的并发访问是不太理想的,大大影响了数据库的性能。可以利用Redis的setnx功能来编写分布式的锁,如果设置返回1说明获取锁成功,否则获取锁失败,实际应用中要考虑的细节要更多

  1. hash

对于一些对象类型除了按string序列化转换后存储,还可以按Hash 类型进行存储。例如我们存储一些网站用户的基本信息, 我们可以使用hmset user:1 name "小明" phone 123456 age 25。类似场景还非常多, 比如存储订单的数据,产品的数据,商家基本信息等;以淘宝购物车为例:

实现对象存储优缺点

  1. string存储
  • set user:1:name xiaoming
  • set user:1:age 24
  • set user:1:phone 123456

优点: 简单直观,每个键对应一个值

缺点: 键数过多,占用内存多,用户信息过于分散

  1. 对象json序列号存储

set user:1 serialize(userInfo)

优点: 编程简单,若使用序列化合理内存使用率高

缺点: 序列化与反序列化有一定开销

  1. hash存储

hmset user:1 name xiaoming age 24 phone 123456

优点: 简单直观,使用合理可减少内存空间消耗

缺点: 要控制ziplist 与hashtable两种编码转换,hashtable会消耗更多内存

  1. list

    1. 消息队列实现

消息队列是大型网站必用中间件,如ActiveMQ、RabbitMQ、Kafka等流行的消息队列中间件,主要用于业务解耦、流量削峰及异步处理实时性低的业务。Redis提供了发布/订阅及阻塞队列功能,能实现一个简单的消息队列系统,大流量吞吐的场景请使用公司Talos等消息队列产品

发布订阅

阻塞队列

  1. 最新上架商品

在交易网站首页经常会有新上架产品推荐的模块, 这个模块是存储了最新上架前 100 名。

这时候使用 Redis 的 list 数据结构,来进行 TOP 100 新上架产品的存储。

Redis ltrim 指令对一个列表进行修剪(trim),这样 list 就会只包含指定范围的指定元素。

  1. set

set 也是存储了一个集合列表功能。和 list 不同,set 具备去重功能。当需要存储一个列表信息,同时要求列表内的元素不能有重复,这时候使用 set 比较合适。与此同时,set 还提供的交集、并集、差集。

例如在交易网站,我们会存储用户感兴趣的商品信息,在进行相似用户分析的时候, 可以通过计算两个不同用户之间感兴趣商品的数量来提供一些依据。

获取到两个用户相似的产品, 然后确定相似产品的类目就可以进行用户分析。

类似的应用场景还有, 社交场景下共同关注好友, 相似兴趣 tag 等场景的支持。

  1. zset

常用于排行榜,如视频网站需要对用户上传视频做排行榜,或点赞数与集合有联系,不能有重复的成员

  1. bitmap

许多开发语言都提供了操作位的功能,合理地使用位能够有效地提高内 存使用率和开发效率。Redis提供了Bitmaps可以实现对位的 操作。

例如每个独立用户是否访问过网站存放在Bitmaps中,将访问的用户记做1,没有访问的用户记做0,用偏移量作为用户的id。设置键的第offset个位的值(从0算起),假设现在有20个用户, userid=0,5,11,15,19的用户对网站进行了访问

  • setbit unique:users:2021-07-20 0 1
  • setbit unique:users:2021-07-20 5 1
  • setbit unique:users:2021-07-20 11 1
  • setbit unique:users:2021-07-20 15 1
  • setbit unique:users:2021-07-20 19 1

获取id=8的用户是否在2021-07-20这天访问过,返回0说明没有访问

  • getbit unique:users:2021-07-20 8

计算2021-07-20这天的独立访问用户数量

  • bitcount unique:users:2021-07-20

假设网站有1亿用户,每天独立访问的用户有5千万,如果每天用集合类型和Bitmaps分别存储活跃用户可以得到表

很明显这种情况下使用Bitmaps能节省很多的内存空间,尤其是随着时间推移节省的内存还是非常可观的。

进阶

  1. 分布式锁

  • 案例1

加锁

// lockKey 锁key

// requestId 锁value

// ttlms 锁过期时间

public boolean tryGetDistributedLock(String lockKey, String requestId, int ttlms) {

    JedisCommands jedis = getJedis();

    try {

        String result = jedis.set(lockKey, requestId, "NX", "PX", ttlms);

        if (StringUtils.isBlank(result)) {

            return false;

        }

        if (LOCK_SUCCESS.equals(result)) {

            return true;

        }

        return false;

    } finally {

        close(jedis);

    }

}

释放锁

public boolean unLock(String lockKey) {

    JedisCommands jedis = getJedis();

    try {

        return jedis.del(key);

    } finally {

        close(jedis);

    }

}

存在问题:


释放其他客户端的锁,添加锁判断

  • 案例2

加锁

// lockKey 锁key

// requestId 锁value

// ttlms 锁过期时间

public boolean tryGetDistributedLock(String lockKey, String requestId, int ttlms) {

    JedisCommands jedis = getJedis();

    try {

        String result = jedis.set(lockKey, requestId, "NX", "PX", ttlms);

        if (StringUtils.isBlank(result)) {

            return false;

        }

        if (LOCK_SUCCESS.equals(result)) {

            return true;

        }

        return false;

    } finally {

        close(jedis);

    }

}

释放锁

public boolean unLock(String lockKey, String requestId) {

    JedisCommands jedis = getJedis();

    try {

        if(StringUtils.equals(requestId, jedis.get(lockKey))){

            return jedis.del(key);

        }

    } finally {

        close(jedis);

    }

}

存在问题:

非原子操作,释放其他客户端的锁,lua脚本,互斥操作

  • 案例3

加锁

// lockKey 锁key

// requestId 锁value

// ttlms 锁过期时间

public boolean tryGetDistributedLock(String lockKey, String requestId, int ttlms) {

    JedisCommands jedis = getJedis();

    try {

        String result = jedis.set(lockKey, requestId, "NX", "PX", ttlms);

        if (StringUtils.isBlank(result)) {

            return false;

        }

        if (LOCK_SUCCESS.equals(result)) {

            return true;

        }

        return false;

    } finally {

        close(jedis);

    }

}

释放锁

 /**

 * 释放分布式锁,用到了lua脚本,保证操作原子性

 * @param lockKey

 * @param requestId

 * @return

 */

public boolean releaseDistributedLock(String lockKey, String requestId) {

    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

    JedisCluster jedisCluster = getJedisCluster();

    if (jedisCluster == null) {

        return false;

    }

    try {

        if (!lockKey.startsWith(prefix)) {

            lockKey = prefix + lockKey;

        }

        Object result = jedisCluster.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

        if (result == null) {

            return false;

        }

        if (RELEASE_SUCCESS.equals(result)) {

            return true;

        }

        return false;

    } finally {

        close(jedisCluster);

    }

}

存在问题:

代码未执行完,锁过期,其他线程拿到锁,重复执行

Redission

官方文档

Redisson的看门狗机制

  1. Redis集群使用pipeline功能

pipeline的使用场景

  1. 命令量大;
  2. 命令结果前后无依赖;

redis中slot概念

redis有固定16384个槽(slot),数据就存储在槽中。 (为什么固定16384,不能更多?)

redis的key通过CRC计算并与16383取模,可以计算出key所在的槽。

int slot = getCRC16(key) & (16384 - 1)

而槽又根据均衡原则分配在不同的数据节点上,每个数据节点上维护一定数量的槽。

redis集群命令路由

首先我们要明确几个知识点:

  1. redis集群有多个数据节点,每个数据节点除了维护自己所属的一批槽点外,还知道集群内其它节点负责的槽点;
  2. redis服务端只负责返回自己槽点内的数据,不会跨节点代理请求;
  3. redis客户端发送的命令,只落在某一个node,不能给多个node发送;

以上3点说明,当一个命令落在了不适合的数据节点上,是不会获得数据。

例如,一条命令get redis-test, 键redis-test的槽点是10000,所属节点是node-3。

如果这个命令发送给node-1的节点,由于node-1不对槽点是10000负责,所以是不会返回期望数据的。

redis 集群 smart client

在上小节,redis集群主要是应对大量请求,如果如上所述,大量的命令都会发生重定向,发送两次请求,显然会存在网络I/O时间问题。

而使用redis的业务多是时间敏感的。

针对以上问题,诞生了smart_client, smart含义体现在,该client将每一条命令发送到正确的node上。

接下来看看smart_client是如何工作的:

  1. client创建了与每个节点的连接池;
  2. client维护了集群中所有 (slot, node) 的映射关系;

private void initializeSlotsCache(Set<HostAndPort> startNodes, GenericObjectPoolConfig poolConfig,

                                  int connectionTimeout, int soTimeout, String password, String clientName) {

  for (HostAndPort hostAndPort : startNodes) {

    Jedis jedis = null;

    try {

      // 初始化redis连接

      jedis = new Jedis(hostAndPort.getHost(), hostAndPort.getPort(), connectionTimeout, soTimeout);

      if (password != null) {

        jedis.auth(password);

      }

      if (clientName != null) {

        jedis.clientSetname(clientName);

      }

      // 缓存slot和节点的对应关系

      cache.discoverClusterNodesAndSlots(jedis);

      break;

    } catch (JedisConnectionException e) {

      // try next nodes

 } finally {

      if (jedis != null) {

        jedis.close();

      }

    }

  }

}
  1. client计算每次命令key的slot,根据slot获取目标node的链接;
private T runWithRetries(final int slot, int attempts, boolean tryRandomNode, boolean asking) {

  if (attempts <= 0) {

    throw new JedisClusterMaxRedirectionsException("Too many Cluster redirections?");

  }



  Jedis connection = null;

  try {



    if (asking) {

      // TODO: Pipeline asking with the original command to make it

 // faster....

 connection = askConnection.get();

      connection.asking();



      // if asking success, reset asking flag

 asking = false;

    } else {

      if (tryRandomNode) {

        connection = connectionHandler.getConnection();

      } else {

        // 从缓存获取key对应的节点

        connection = connectionHandler.getConnectionFromSlot(slot);

      }

    }



    return execute(connection);



  } catch (JedisNoReachableClusterNodeException jnrcne) {

    throw jnrcne;

  } catch (JedisConnectionException jce) {

    // release current connection before recursion

 releaseConnection(connection);

    connection = null;



    if (attempts <= 1) {

      //We need this because if node is not reachable anymore - we need to finally initiate slots renewing,

 //or we can stuck with cluster state without one node in opposite case.

 //But now if maxAttempts = 1 or 2 we will do it too often. For each time-outed request.

 //TODO make tracking of successful/unsuccessful operations for node - do renewing only

 //if there were no successful responses from this node last few seconds

 this.connectionHandler.renewSlotCache();

    }



    return runWithRetries(slot, attempts - 1, tryRandomNode, asking);

  } catch (JedisRedirectionException jre) {

    // if MOVED redirection occurred,

 if (jre instanceof JedisMovedDataException) {

      // it rebuilds cluster's slot cache

 // recommended by Redis cluster specification

 this.connectionHandler.renewSlotCache(connection);

    }



    // release current connection before recursion or renewing

 releaseConnection(connection);

    connection = null;



    if (jre instanceof JedisAskDataException) {

      asking = true;

      askConnection.set(this.connectionHandler.getConnectionFromNode(jre.getTargetNode()));

    } else if (jre instanceof JedisMovedDataException) {

    } else {

      throw new JedisClusterException(jre);

    }



    return runWithRetries(slot, attempts - 1, false, asking);

  } finally {

    releaseConnection(connection);

  }

}
  1. client使用正确的连接发送命令;
  2. 当slot发生了迁移,client要重定向到新的node,已经更新本地的(slot, node) 映射;

redis集群不支持pipeline的原因

综上,我们可以总结如下:

  1. pipeline将多个命令只发给一个node;但是这些命令的槽点可能对应其它node;
  2. node间不负责代理转发请求;
  3. 如果key不在接受到命令的node上,会没有正确结果;

所以对于redis集群来说,简单的使用pipeline并不能达到我们的目的。

解决方案

方案1

结合smart_client的特性,保存有slot -> node的映射关系,方案如下:

  1. 对一批命令里的key分别计算槽点,获得对应的node链接;
  2. 对同属同一个node的key进行归档,得到每个node需要执行的命令列表;
  3. 之后对每个node链接分别执行pipeline;

实现:

方案2

使用hash_tag,这个方案是在写入key的时候,将多个key写入同一个node。{adc}>1,{adc}>2

缺点:数据分散不均匀,集群流量不均。

  1. 缓存穿透,击穿,雪崩

缓存穿透

  • 缓存穿透是指查询一个不存在的数据,由于缓存是不命中时被动写的,如果从DB查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到DB去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了。
  • 怎么解决?
  1. 缓存空值,不会查数据库。
  2. 采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,查询不存在的数据会被这个bitmap拦截掉,从而避免了对DB的查询压力。
  • 布隆过滤器的原理:当一个元素被加入集合时,通过K个哈希函数将这个元素映射成一个位数组中的K个点,把它们置为1。查询时,将元素通过哈希函数映射之后会得到k个点,如果这些点有任何一个0,则被检元素一定不在,直接返回;如果都是1,则查询元素很可能存在,就会去查询Redis和数据库。
  • 布隆过滤器一般用于在大数据量的集合中判定某元素是否存在。

缓存击穿

  • 缓存击穿:大量的请求同时查询一个 key 时,此时这个 key 正好失效了,就会导致大量的请求都落到数据库。缓存击穿是查询缓存中失效的 key,而缓存穿透是查询不存在的 key。

  • 解决方法:

    • 加互斥锁。在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他的线程拿不到锁就阻塞等着,等到第一个线程将数据写入缓存后,直接走缓存。
    • 热点数据不过期。直接将缓存设置为不过期,然后由定时任务去异步加载数据,更新缓存。这种方式适用于比较极端的场景,例如流量特别特别大的场景,使用时需要考虑业务能接受数据不一致的时间,还有就是异常情况的处理,保证缓存可以定时刷新。

缓存雪崩

  • 缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重挂掉。
  • 解决方法:
  1. 在原有的失效时间基础上增加一个随机值,使得过期时间分散一些。这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
  2. 加锁排队可以起到缓冲的作用,防止大量的请求同时操作数据库,但它的缺点是增加了系统的响应时间降低了系统的 吞吐量,牺牲了一部分用户体验。当缓存未查询到时,对要请求的 key 进行加锁,只允许一个线程去数据库中查,其他线程等候排队。
  3. 设置二级缓存。二级缓存指的是除了 Redis 本身的缓存,再设置一层缓存,当 Redis 失效之后,先去查询二级缓存。例如可以设置一个本地缓存,在 Redis 缓存失效的时候先去查询本地缓存而非查询数据库。
  1. 热点Key,大Key

热点Key

  • 在Redis中,我们把访问频率高的Key,称为热Key。比如突然有几十万的请求去访问redis中某个特定的Key,那么这样会造成redis服务器短时间流量过于集中,很可能导致redis的服务器宕机。那么接下来对这个Key的请求,都会直接请求到我们的后端数据库中,数据库性能本来就不高,这样就可能直接压垮数据库,进而导致后端服务不可用。

  • 如何识别热点Key?

    • 结合业务场景,判断哪些是热Key,热卖商品、热点新闻、热点评论、明星直播
    • 在客户端写程序统计上报,对我们的业务代码有一定的侵入性。
    • 服务代理层上报。
    • JD-HotKey
  • 解决

    • 使用二级缓存,即JVM本地缓存,减少Redis的读请求。

大Key

  • 定义:

    • 单个简单的key存储的value很大
    • hash, set,zset,list 中存储过多的元素
  • 问题:

    • 读写bigkey会导致超时严重,甚至阻塞服务。redis单线程
    • 大key相关的删除或者自动过期时,会出现qps突降或者突升的情况,极端情况下,会造成主从复制异常,Redis服务阻塞无法响应请求。
  • 解决方案

    • 设计时避免大key
    • 大key拆分