Redis常用的数据结构及其使用场景

字符串(String)

string 是 redis 最基本的类型,你可以理解成与 Memcached 一模一样的类型,一个 key 对应一个 value。

string 类型是二进制安全的。意思是 redis 的 string 可以包含任何数据,比如jpg图片或者序列化的对象。

string 类型是 Redis 最基本的数据类型,string 类型的值最大能存储 512MB。

分布式ID生成器

相信大家经常会遇到需要生成唯一ID的场景,例如标识码每次请求、生成一个订单号、创建用户。

分布式ID生成器需要满足以下特性:

  • 趋势递增,mysql是最常用的数据库,如果ID不是趋势递增,那么B+树为了维护ID有序性,会频繁在索引的中间位置插入节点,从而影响后面节点的位置,甚至频繁导致页分裂,这对于性能的影响是极大的。
  • 全局唯一性,ID不唯一就会出现主键冲突。
  • 高性能,生成ID是高频操作,如果性能缓慢,系统的整体性能就会受到限制。
  • 高可用,也就是在给定的时间间隔内,一个系统总的可用时间占的比例。
  • 存储空间小,例如对于mysql的B+树,普通索引会存储主键值,主键越大,每个Page可以存储的数据就越少,访问磁盘I/O的次数就会增加。

Redis集群能保证高可用和高性能,为了节省内存,ID可以使用数字的形式,并且通过递增的方式来创建新的ID。为了防止重启数据丢失,还需要开启redis的aof持久化。虽然,开启aof持久化,为了性能配置everysec策略还是有可能丢失1s的数据,因此还可以使用一个异步机制(例如将id发送到mq消息队列中)将生成的最大ID持久化到一个mysql。

设计思路

假设订单ID生成器的键是"counter:order",当应用服务启动时先从mysql中查询出最大值M,然后执行exists命令判断是否存在键。

  • 如果redis中不存在键,则执行set命令将值M写入redis。
  • 如果redis中存在键,值为N,则比较M和N的值,执行SET命令将M与N的最大值写入redis,相等则不操作。
  • 应用服务启动完成后,在每次需要生成ID时,应用程序就向redis服务器发送incr命令。
  • 应用程序将获取到的ID值发送到mq对象,消费者监听队列把值持久化到mysql。

代码实现

javascript 复制代码
  async generateDistributedId(key, value, callback: (id) => void) {
    try {
      let flag = await this.redis.exists(key)
      if (flag == 0) {
        //不存在该健
        await this.redis.set(key, value??0)//不存在该键,就初始化为0或value

      } else {
        let redis_value = await this.redis.get(key)
        await this.redis.set(key, Math.max(redis_value, value))
      }
      // 使用 INCR 命令对指定的键进行自增操作 
      const id = await this.redis.incr(key);
      callback(id)
      return id;
    } catch (error) {
      this.logger.error(`生成分布式 ID 时出错: ${error}`);
      throw error;
    }
  }

当然,还需要其他的分布式ID生成发送,例如,雪花算法、UUID等

列表(List)

Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)。列表最多可以存储 2^32 - 1 个元素。

分布式系统中必备的一个中间件就是消息队列,它可以进行服务间异步解耦、流量消峰,实现最终一致性。目前市面上已有的消息队列又kaqfka、pulsar等。

那么,redis适合做消息队列吗?想要回答这个问题之前,得先从本质思考

  • 消息队列提供了什么特性?
  • Redis如何实现消息队列?是否满足存取需求?

什么是消息队列

消息队列是一种异步的服务间通信方式,适用于分布式和微服务架构。消息在被处理和删除之前一直存储在队列上。

它基于先进先出的原则,允许生产者向队列中发送消息,而消费者则可以从队列中获取消息并进行处理。

消息队列通常被用于解耦应用程序的各个组件,实现异步通信、削峰填谷、解耦合、流量控制等功能。

  • Producer:消息生产者,负责产生和发送消息到消息处理中心(Broker)。
  • Broker:消息处理中心,负责消息存储、确认、重试等,一般会包含多个queue。
  • Consumer:消息消费者,负责从Broker中获取消息,并进行相应处理。

消息队列在实际应用中包括如下4个场景。

  • 应用耦合:发送方、接收方的系统之间不需要互相了解,只需要认识消息。多应用间通过消息队列对同一消息进行处理,避免调用接口失败导致整个过程失败。
  • 异步处理:多应用对消息队列中的同一消息进行处理,应用间并发处理消息,相比串行处理,减少处理时间。
  • 限流削峰:广泛应用于秒杀或抢购活动中,避免流量过大导致应用系统"挂掉"的情况。
  • 消息驱动的系统:系统中有消息队列、消息生产者和消息消费者,生产者负责产生消息,消费者负责处理消息。

消息队列满足哪些特性

  • 消息有序性:消息是异步处理的,但是消费者需要按照生产者发送消息的顺序来消费,避免出现后发送的消息被先处理的情况。
  • 重复消息处理:当因为网络问题出现消息重传时,消费者可能收到多条重复消息。同样的消息重复多次可能造成同一业务逻辑被多次执行,在这种情况下,应用系统需要确保幂等性。
  • 可靠性:保证一次性传递消息。如果发送消息时消费者不可用,那么消息队列会保留消息,直到成功传递它。当消费者重启后,可以继续读取消息进行处理,防止消息遗漏。
  • LPUSH:生产者使用LPUSH命令将消息插入队列头部,如果key不存在则会创建一个空的队列再插入消息,LPUSH命令的返回值表示插入队列的消息个数。
  • 使用BLPOP、BRPOP阻塞读取的命令,消费者在读取队列没有数据时会自动阻塞,直到有新的消息写入队列,才继续读取消息执行业务逻辑。

注意:Lists方式实现的消息队列并没有提供类似kafka的消费者组的概念,由多个消费者组成一个消费者组来分担处理队列消息的任务。如果,生产者发送消息的速度过快,消费者处理不过来,则会导致消息积压,占用过多的内存。从redis5.0版本开始,可以使用Stream来实现。

重复消费解决方案

  • 消息队列自动为每条消息生成一个全局ID。
  • 生产者为每条消息创建一个全局ID,消费者把处理过的消息ID记录下来判断是否重复。

其实这就是幂等,对于同一条消息,消费者收到后处理一次的结果和处理多次的结果是一致的。

消息可靠性解决方案

消费者读取消息,处理过程中宕机了就会导致消息没有处理完成,可是数据已经不在队列中了。这种现象的本质就是消费者在处理消息时崩溃了,无法再读取消息,缺乏一个消息确认的可靠机制。可以使用BLMOVE命令,以阻塞的方式从原队列中读取消息,同时把这条消息复制到另一个队列中(备份),并且是原子操作。

设计思路

  • 生产者使用LPUSH命令将消息依次存入队列队头。
  • 消费者消费消息时在while循环使用BLMOVE,以阻塞的方式从队列队尾弹出消息,同时把该消息复制到备份队列中,该操作是原子性的。
  • 如果消费消息成功,就是用LREM把备份队列中的对应的消息删除,从而实现ACK确认机制。
  • 如果消费异常,那么应用程序使用BRPOP命令从备份队列再次读取消息。

代码实现

javascript 复制代码
    // 定义队列名称 
  private MAIN_QUEUE = 'main_queue';
  private BACKUP_QUEUE = 'backup_queue';
  private DEAD_LETTER_QUEUE = 'dead_letter_queue';

  // 定义最大重试次数 
  private MAX_RETRIES = 3;
  // 定义消息过期时间(单位:毫秒) 
  private MESSAGE_EXPIRATION_TIME = 60000;

  // 生产者:向主队列中添加消息 
  async produceMessage(message) {
    try {
      const msgId = Date.now() + '_' + Math.random().toString(36).slice(2);
      const fullMsg = {
        id: msgId,
        content: message,
        retries: 0,
        createdAt: Date.now()
      };
      await this.redis.lpush(this.MAIN_QUEUE, JSON.stringify(fullMsg));
      this.logger.info(`Produced  message: ${JSON.stringify(fullMsg)}`);
    } catch (error) {
      this.logger.error(`Error  producing message: ${error}`);
    }
  }

  // 消费者:从主队列消费消息 
  async consumeMessages(processMessageCallback: (msg: any) => Promise<void>) {
    while (true) {
      try {
        // 原子性地将消息从主队列移到备份队列 
        const msg = await this.redis.blmove(
          this.MAIN_QUEUE,
          this.BACKUP_QUEUE,
          'RIGHT',
          'LEFT',
          0
        );

        if (msg) {
          const parsedMsg = JSON.parse(msg);

          // 检查消息是否过期 
          if (Date.now() - parsedMsg.createdAt > this.MESSAGE_EXPIRATION_TIME) {
            // 消息过期,将其移动到死信队列 
            await this.redis.lrem(this.BACKUP_QUEUE, 1, msg);
            await this.redis.lpush(this.DEAD_LETTER_QUEUE, JSON.stringify(parsedMsg));
            this.logger.warn(`Message  ${parsedMsg}  has expired and moved to dead letter queue.`);
            continue;
          }

          this.logger.info(`Processing  message: ${parsedMsg}`);

          //消息处理 
          await processMessageCallback(parsedMsg);

          // 确认消费成功,从备份队列中移除消息 
          await this.redis.lrem(this.BACKUP_QUEUE, 1, msg);
        }
      } catch (error) {
        this.logger.error(`Error  consuming message:${error}`);
        // 从备份队列取出消息进行重试 
        const [, retryMsg] = await this.redis.brpop(this.BACKUP_QUEUE, 0);
        const parsedRetryMsg = JSON.parse(retryMsg);

        // 增加重试次数 
        parsedRetryMsg.retries++;

        if (parsedRetryMsg.retries >= this.MAX_RETRIES) {
          // 达到最大重试次数,将消息移到死信队列 
          await this.redis.lpush(this.DEAD_LETTER_QUEUE, JSON.stringify(parsedRetryMsg));
          this.logger.warn(`Message  ${parsedRetryMsg}  moved to dead letter queue.`);
        } else {
          // 未达到最大重试次数,重新放回主队列 
          await this.redis.lpush(this.MAIN_QUEUE, JSON.stringify(parsedRetryMsg));
          this.logger.info(`Message  ${parsedRetryMsg}  will be retried.`);
        }
      }
    }
  }

集合(Set)

Redis 的 Set 是 string 类型的无序集合。集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。

使用场景

需要存储多个元素,并且要求不能出现重复数据,无须考虑元素的有序性时,可以使用Set。Set还支持在集合之间做交集、并集、差集操作。例如统计如下场景中多个集合元素的聚合结果。

  • 统计多个元素的共有数据(交集)。
  • 对于两个集合,统计其中的一个独有元素(差集)。
  • 统计多个集合的所有元素(并集)。

常见的使用场景如下:

  • 社交软件中共同关注:通过交集实现。
  • 每日新增关注数:对近两天的总注册用户量集合取差集。
  • 打标签:为自己收藏的每一篇文章打标签,例如微信收藏功能,这样可以快速找到被添加了某个标签的所有文章。

代码实现

javascript 复制代码
  async getCommonElements(keys:RedisKey[], storeKey:RedisKey) {
    if (!keys || keys.length < 2) {
      throw new Error("至少需要2个集合键");
    }

    // 执行交集操作 
    if (storeKey) {
      // 存储结果到新键并返回数量 
      const count = await this.redis.sinterstore(storeKey, ...keys);
      return count;
    } else {
      // 直接获取交集数据 
      const elements = await this.redis.sinter(...keys);
      return elements;
    }
  }

哈希(Hash)

Redis hash 是一个键值(key=>value)对集合,类似于一个小型的 NoSQL 数据库。

Redis hash 是一个 string 类型的 field 和 value 的映射表,hash 特别适合用于存储对象。

每个哈希最多可以存储 2^32 - 1 个键值对。

常用命令

  • HSET key field value:设置哈希表中字段的值。
  • HGET key field:获取哈希表中字段的值。
  • HGETALL key:获取哈希表中所有字段和值。
  • HDEL key field:删除哈希表中的一个或多个字段。
  • DEL key:删除整个哈希表。
  • HLEN key:查询散列表中有多少个field-value pairs。
  • HSETNX key value:当key不存在时配置value,否则什么也不干。

有序集合(Sorted Set)

Sorted Set和Set类似,是一种集合类型,这种集合中不会出现重复的member(数据)。它们之间的区别在于:Sorted Set中的元素由两部分组成,分别是member和score。

member会关联一个double类型的score,Sorted Set默认会根据这个score对member从小到大进行排序,如果member关联的score相同,则按照字符串的字典顺序排序。

应用场景

  • 排行榜:维护大型在线游戏中根据分数排名的Top10有序列表。
  • 速率限流器:根据排序集合构建滑动窗口速率限制器。
  • 延迟队列:使用score存储过期时间,从小到大排序,最靠前的就是最先到期的数据。

例如,很多地方都会用到排行榜功能,如微博热搜、游戏战力排行榜等,我们可以使用sorted sets实现一个实时游戏高分排行榜。

玩家的得分越高,排名越靠前,如果分数相同则先达到该分数的玩家排在前面,游戏排行榜提供的功能如下:

  • 按照分数从高到低排名,查询前N位玩家的信息。
  • 新注册玩家,需要把新玩家信息添加到排行榜中。
  • 能查看某个玩家的排名和分数。

sorted sets的每个元素都由member和score两部分组成,可以利用score进行排序,正好满足我们的需求。用score保存玩家的游戏得分,member保存玩家ID。那么如何实现最先达到该分数的玩家排在前面这个功能。我们可以指定一个非常大的时间作为基准时间,时间排序值 = (基准时间-玩家达到分数时间)/基准时间。

以上公式得到的结果一定小于1,正好可以作为score的小数部分。越早达到,这个值就越大,满足排序要求。

Bitmap

在移动应用的业务场景中,我们需要保存这样的信息:一个key关联了一个数据集合。

常见的场景如下:

  • 用户在线状态统计:可以使用Bitmap来记录用户的在线状态,其中每位表示一个用户的在线状态(在线为1,离线为0).这样可以高效地统计在线用户数量和在线用户的分布情况。
  • 用户签到记录:Bitmap可以用于记录用户的签到情况,其中每位表示一个日期(已签到为1,未签到为0).这样可以轻松统计用户的连续签到天数、活跃用户数等信息。
  • 页面点击量统计:Bitmap可以用于统计网站的页面点击量,其中每位表示一个页面的点击情况(点击为1,未点击为0).这样可以快速获取每个页面的点击量以及总点击量。

面向bit的操作是在字符串类型上定义的,将bitmap存储在字符串中,每个字符都是由8bit组成的数据,其中的每位只能是1或0.字符串类型的最大容量是512MB,所以一个Bitmap最多可配置2^32个不同位。

bitmap解决的是二值状态统计场景问题。也就是集合中的元素的值只有0和1两种,在签到打卡和用户是否登录的场景中,只需记录签到或未签到。

假如我们使用redis的字符串类型判断用户是否登录,如果以字符串的形式存储100万个用户的登录状态,那么需要存储100万个字符串,内存开销太大。因为redis是用c语言编写的redis提供的字符串类型本质上还是c语言中的字节数组,末尾还需要加上"\0"的那种,除此之外,redis还在此基础上封装了一层,记录数组已使用的长度,数组分配的空间总长度等,组成一个结构体。这些都是需要占用额外的存储空间。

bitmap的底层使用字符串类型的SDS数据结构来保存位数组,redis把每字节数组的8bit利用起来,每位表示一个元素的二值状态(不是1就是0).可以将bitmap看作一个以bit为单位的数组,数组的每位只能存储0或者1,数组的每位下标在bitmap中叫做offset偏移量。

使用场景

用户登录判断,bitmap提供了GETBIT、SETBIT操作,通过一个偏移值offset对bit数组的偏移量offset的bit进行读/写操作,需要注意的是,offset从0开始。bitmap的大小为byte的整数倍,例如你使用了31个bit,但是实际上为占用32bit,没用的会自动补0。

SETBIT:

例如:记录用户在2025年3月1日和2025年3月31日打卡情况。

javascript 复制代码
SETBIT uid:sign:xxxx:202503 0 1
SETBIT uid:sign:xxx:202503 31 1

执行以上两个命令后,bitmap的数据就是100000000000000000000000000010(一共32bit)。
相关命令

  1. SETBIT:

    • 设置或清除位的值。
    • 语法: SETBIT key offset value
    • 例子: SETBIT mykey 7 1(将 mykey 的第 8 位设为 1
  2. GETBIT:

    • 获取指定偏移量的位的值。
    • 语法: GETBIT key offset
    • 例子: GETBIT mykey 7(获取 mykey 的第 8 位的值)
  3. BITCOUNT:

    • 计算字符串中被设置为 1 的位的数量。
    • 语法: BITCOUNT key [start end]
    • 例子: BITCOUNT mykey(计算 mykey 中所有位中 1 的数量)
  4. BITOP:

    • 对一个或多个字符串执行按位运算(AND、OR、XOR、NOT)。
    • 语法: BITOP operation destkey key [key ...]
    • 例子: BITOP AND resultkey key1 key2(对 key1key2 执行按位与运算,并将结果存储在 resultkey 中)
  5. BITPOS:

    • 找到字符串中第一个设置为 10 的位的索引。
    • 语法: BITPOS key bit [start end]
    • 例子: BITPOS mykey 1(查找 mykey 中第一个 1 的位置)

使用场景

  • 用户行为记录: 通过 Bitmap 可以高效地记录某个用户是否在某一天执行了某个操作,比如签到。
  • 大规模布尔值存储: 可以在极小的空间内存储大量布尔值。
  • 快速统计 : 使用 BITCOUNT 可以快速统计某个集合或用户群体的某个行为发生次数。

Bitmap 的优点是空间效率高和操作速度快,适合用于需要处理大量布尔值或类似布尔值的数据场景。通过这些命令,你可以非常灵活地操作位数组,并根据具体需求进行优化和调整。

HyperLogLog

在移动互联网的业务场景中,数据量很大,系统需要保存这样的信息:一个key关联了一个数据集合,同时将这个数据集合以统计报表的形式呈现给运营人员。例如:

  • 统计一个App的日活、月活人数。
  • 统计一个页面每天被多少个不同账户访问。
  • 统计用户每天搜索不同词条的个数。
  • 统计注册IP地址数。

这些是典型的HyperLogLog(基数统计)应用场景。基数统计指统计一个集合中不重复元素的数量,这些不重复的元素被称为基数。

基数统计

HyperLogLog是一种概率数据结构,用于估计集合的基数。每个HyperLogLog最多消耗12kb内存,在标准误差0.81%的前提下,可以计算2^64个元素的基数。其主要特点如下。

  • 高效存储:HyperLogLog的内存消耗是固定的,与集合中的元素数量无关。这使得它特别适用于处理大规模数据集,因为它不需要存储所有不同的元素,只需要存储估计基数所需的信息。
  • 概率估计:HyperLogLog提供的结果是概率性的,不是精确的基数计数。它通过哈希函数将输入元素映射到Bitmap中的某些位置,并基于Bitmap的统计信息来估计基数的。由于这是一种概率方法,因此可能存在一定的误差,但在实际应用中,这个误差通常可以接受。
  • 高速计算:HyperLogLog可以在常量时间内计算估计的基数,无论集合的大小如何。

应用场景

HyperLogLog的主要使用场景是基数统计。例如,海量网页访问量的统计,统计文章每天被多少用户访问过,一个用户一天访问多次只能记一次。

对于上面的场景,可以使用Sets、Bitmap和HyperLogLog来解决。

  • Sets:统计精度高,对于少量的数据统计建议使用,大量的数据统计会占用很大的内存空间。
  • Bitmap:位图算法,统计精度高,内存占用比Sets少,但是在统计大量数据时还是会占用大量内存。
  • HyperLogLog:存在一定的误差,占用内存少(稳定占用12kb左右)。
    Redis 中的 HyperLogLog 是一种概率性数据结构,用于估计集合中唯一元素的基数(即不重复元素的数量)。HyperLogLog 的优点是它占用极小的内存空间(大约 12 KB),即使是非常大的数据集也能保持这种低内存消耗。虽然它是一个估计器,但误差率非常小(大约 0.81%)。

以下是 Redis 中与 HyperLogLog 相关的命令:

  1. PFADD:

    • 向 HyperLogLog 添加元素。
    • 语法: PFADD key element [element ...]
    • 例子: PFADD hllkey user1 user2 user3(将 user1user2user3 添加到 hllkey
  2. PFCOUNT:

    • 返回存储在 HyperLogLog 中的唯一元素的近似数量。
    • 语法: PFCOUNT key [key ...]
    • 例子: PFCOUNT hllkey(返回 hllkey 中估计的唯一元素数量)
  3. PFMERGE:

    • 合并多个 HyperLogLog 数据结构为一个。
    • 语法: PFMERGE destkey sourcekey [sourcekey ...]
    • 例子: PFMERGE mergedkey hllkey1 hllkey2(将 hllkey1hllkey2 合并到 mergedkey

Geospatial

基于位置服务(LBS)

经纬度是由经度与维度组成的坐标系统,又称为地理坐标系统。LBS是围绕用户当前地理位置的数据而展开的服务,为用户提供精准的"邂逅"服务。LBS的特点如下:

  • 以"我"为中心,搜索附件的Ta。
  • 以"我"当前的地理位置为准,计算出别人和"我"之间的距离。
  • 按"我"与别人距离的远近排序,筛选出离"我"最近的用户。
    Redis 提供了 Geospatial 数据类型,用于存储和查询地理空间信息(如经纬度坐标)。这些命令非常适用于需要处理地理位置信息的应用,比如基于位置的服务、地图应用等。

GeoHash编码

GeoHash编码是将二维经纬度编码转换为一维,为地址位置分区的一种算法。其核心思想是区间二分:将地球编码看成一个二维平面,然后将这个平面递归均分为更小的子块。这个过程可以分为三步。

  1. 将经、纬度分别变成一个N位二进制数。
  2. 将经、纬度的二进制数合并。
  3. 按照Base32进行编码。

经纬度编码

GeoHash编码会把一个经度编码成一个N位的二进制数,例如对经度范围(-180,180)做√次二分区操作,其中N可以自定义。

在进行第一次二分区时,经度范围[-180,180]会被分成(-180,0)和[0,180]两个子区间(我称之为左、右分区)。

此时,我们可以查看一下要编码的经度值落在了左分区还是右分区。如果落在左分区,就用0表示;如果落在右分区,就用1表示。这样一来,每做完一次二分区,我们就可以得到1立编码值(不是0就是1)。

再对经度值所属的分区做一次二分区,查看经度值落在了二分区后的左分区还是右分区,然后按照刚才的规则再做1位编码。当做完N次二分区后,经度值就可以用一个N位的数来表示了。

所有的地图元素坐标都被放置于唯一的方格中,分区次数越多,方格越小,坐标越精确。

然后对这些方格进行整数编码,距离越近的方格编码越接近。

编码之后,每个地图元素的坐标都将变成一个整数,通过这个整数可以还原出元素的坐标,整数越长,还原出来的坐标值的损失就越小。对于"附近的人"这个功能而言,损失的一点精度可以忽略不计。

例如,对经度值169.99进行4位编码(N=4,做4次分区),把经度区间(-180,180)分成了左分区(-180,0)和右分区[0,180]。

  • 169.99属于右分区,使用1表示第一次分区编码。
  • 再将169.99经过第一次划分所属的[0,180]区间继续分成(0,90)和[90,180],169.99依然在右区间,编码为1。
  • 将[90,180]分为(90,135)和[135,180],这次落在左分区,编码为0。
    纬度的编码思路与经度一样,不再赘述。

Geospatial 命令

  1. GEOADD:

    • 将地理空间位置添加到指定的键中。
    • 语法: GEOADD key longitude latitude member [longitude latitude member ...]
    • 例子: GEOADD locations 13.361389 38.115556 "Palermo" 15.087269 37.502669 "Catania"
  2. GEOPOS:

    • 返回一个或多个成员的地理空间位置(经纬度)。
    • 语法: GEOPOS key member [member ...]
    • 例子: GEOPOS locations "Palermo" "Catania"
  3. GEODIST:

    • 返回两个给定成员之间的距离。
    • 语法: GEODIST key member1 member2 [unit]
    • 可选单位: m(米), km(公里), mi(英里), ft(英尺)
    • 例子: GEODIST locations "Palermo" "Catania" km
  4. GEORADIUS (已弃用,推荐使用 GEOSEARCH):

    • 在给定的圆形范围内查找成员,基于给定的经纬度。
    • 语法: GEORADIUS key longitude latitude radius unit [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
    • 例子: GEORADIUS locations 15 37 200 km WITHDIST
  5. GEORADIUSBYMEMBER (已弃用,推荐使用 GEOSEARCH):

    • 在给定的圆形范围内查找成员,基于给定的已有成员的位置。
    • 语法: GEORADIUSBYMEMBER key member radius unit [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
    • 例子: GEORADIUSBYMEMBER locations "Palermo" 100 km WITHDIST
  6. GEOSEARCH:

    • 根据指定的半径或边界框条件搜索地理位置。
    • 语法: GEOSEARCH key FROMMEMBER member|FROMLOC long lat BYRADIUS radius unit|BYBOX width height unit [ASC|DESC] [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count]
    • 例子: GEOSEARCH locations FROMMEMBER "Palermo" BYRADIUS 100 km ASC WITHDIST
  7. GEOSEARCHSTORE:

    • 执行 GEOSEARCH 并将结果存储在一个新的或现有的键中。
    • 语法: GEOSEARCHSTORE dest key FROMMEMBER member|FROMLOC long lat BYRADIUS radius unit|BYBOX width height unit [ASC|DESC] [COUNT count] [STOREDIST]
    • 例子: GEOSEARCHSTORE nearby_locations locations FROMMEMBER "Palermo" BYRADIUS 100 km

Stream

stream是redis5.0专门为消息队列设计的数据类型,借鉴kafka的消费组的设计思想,提供消费者组的概念,同时提供消息的持久化和主从复制机制。客户端可以访问任何时刻的数据,并且能记住每个客户端的访问位置,从而保证消息不丢失。

需要注意的是,stream是一种超轻量级的MQ,并没有完全实现消息队列的所有设计要点,所以它的使用场景需要考虑业务的数据量和对性能】可靠性的需求。对于系统消息量不大、可以容忍数据丢失的场景,使用stream作为消息队列就能享受高性能快速读/写消息的优势。

Redis Streams 是一种强大的数据结构,旨在支持高效的消息队列和事件流处理。它允许在 Redis 中存储和处理时间序列数据,提供了强大的功能用于生产者和消费者之间的消息传递。以下是 Redis Streams 相关的主要命令及其功能:

Stream 命令

  1. XADD:

    • 向流中添加新的条目。
    • 语法: XADD key ID field value [field value ...]
    • ID 可以为 *,让 Redis 自动生成一个唯一 ID。
    • 例子: XADD mystream * sensor-id 1234 temperature 19.8
  2. XRANGE:

    • 读取流中某个范围的条目。
    • 语法: XRANGE key start end [COUNT count]
    • startend 可以为特殊值 -+,表示流的开始和结束。
    • 例子: XRANGE mystream - + COUNT 10
  3. XREVRANGE:

    • 以相反的顺序读取流中某个范围的条目。
    • 语法: XREVRANGE key end start [COUNT count]
    • 例子: XREVRANGE mystream + - COUNT 10
  4. XLEN:

    • 返回流中的条目数。
    • 语法: XLEN key
    • 例子: XLEN mystream
  5. XREAD:

    • 从一个或多个流中读取数据。
    • 语法: XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]
    • 支持阻塞操作,等待新的条目。
    • 例子: XREAD STREAMS mystream 0
  6. XGROUP CREATE:

    • 创建消费者组。
    • 语法: XGROUP CREATE key groupname id|$ [MKSTREAM]
    • id 可以为 $,表示从最后一个条目开始消费。
    • 例子: XGROUP CREATE mystream mygroup $
  7. XREADGROUP:

    • 读取消息并标记为已被消费者组消费。
    • 语法: XREADGROUP GROUP groupname consumer [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]
    • 例子: XREADGROUP GROUP mygroup consumer1 STREAMS mystream >
  8. XACK:

    • 确认消息已被处理。
    • 语法: XACK key groupname id [id ...]
    • 例子: XACK mystream mygroup 1526569495631-0
  9. XPENDING:

    • 查看消费者组的待处理消息。
    • 语法: XPENDING key groupname [start end count] [consumer]
    • 例子: XPENDING mystream mygroup
  10. XCLAIM:

    • 将消息的所有权转移到另一个消费者。
    • 语法: XCLAIM key groupname consumer min-idle-time id [id ...] [RETRYCOUNT count] [FORCE] [JUSTID]
    • 例子: XCLAIM mystream mygroup consumer2 0 1526569495631-0
  11. XDEL:

    • 删除流中的条目。
    • 语法: XDEL key id [id ...]
    • 例子: XDEL mystream 1526569495631-0
  12. XTRIM:

    • 修剪流以减少其长度。
    • 语法: XTRIM key MAXLEN [~] count
    • 例子: XTRIM mystream MAXLEN 1000

使用场景

  • 消息队列: 用于实现高效的生产者-消费者模型。
  • 事件溯源: 在事件驱动架构中记录和处理事件。
  • 日志处理: 实时收集和分析日志数据。

Redis Streams 提供了一种高效且灵活的方法来处理实时数据流,适合多种应用场景,如实时数据处理、日志分析、消息队列等。通过消费者组和阻塞读取等功能,Redis Streams 可以处理复杂的数据流管理需求。

javascript 复制代码
const Redis = require('ioredis');
const redis = new Redis();

const streamKey = 'mystream';
const groupName = 'mygroup';
const consumerName = 'consumer1';

// 创建消费者组
async function createConsumerGroup() {
  try {
    await redis.xgroup('CREATE', streamKey, groupName, '$', 'MKSTREAM');
    console.log(`Consumer group ${groupName} created.`);
  } catch (err) {
    if (err.message.includes('BUSYGROUP Consumer Group name already exists')) {
      console.log(`Consumer group ${groupName} already exists.`);
    } else {
      throw err;
    }
  }
}

// 生产者:向流中添加消息
async function produceMessages() {
  const messageId = await redis.xadd(streamKey, '*', 'field1', 'value1');
  console.log(`Produced message with ID: ${messageId}`);
}

// 消费者:读取并确认消息
async function consumeMessages() {
  while (true) {
    try {
      const messages = await redis.xreadgroup(
        'GROUP', groupName, consumerName,
        'BLOCK', 0,
        'STREAMS', streamKey, '>'
      );

      if (messages) {
        messages.forEach(([_stream, entries]) => {
          entries.forEach(([id, fields]) => {
            console.log(`Consumed message ID: ${id}, fields: ${fields}`);
            // 处理消息后,确认消息
            redis.xack(streamKey, groupName, id);
            console.log(`Acknowledged message ID: ${id}`);
          });
        });
      }
    } catch (err) {
      console.error('Error reading from stream:', err);
      break;
    }
  }
}

(async () => {
  await createConsumerGroup();
  produceMessages(); // 生产消息

  // 启动消费者,可以启动多个消费者
  consumeMessages();
  
})();

Radix Tree

前缀树被用于高效存储和查找字符串集合。它将字符串按照前缀拆分成一个个字符,并将每个字符作为一个节点存储在树中。

当插入一个field-value pairs时,redis会将field拆分成一个个字符,并根据字符在radix tree中的位置找到合适的节点,如果该节点不存在,则创建新节点并添加到radix tree中。

当所有字符添加完毕后,将值对象指针保存到最后一个节点中。当查询一个field时,redis按照字符顺序遍历radix tree,如果发现某个字符不存在于树中,则表示field不存在;如果最后一个节点表示一个完整的field,则返回对应的值对象。利用前缀树就可以避免相同字符串被重复存储。

Redis高性能的原因

  • 基于内存实现

    读、写操作都是在内存上完成的。内存直接由cpu控制,也就是由cpu内部集成内存控制器,所以说内存是直接与cpu对接的,享受与cpu通信的"最优带宽"。redis将数据存储在内存中,读/写操作不会被磁盘的I/O速度限制。

  • I/O多路复用模型

    redis采用I/O多路复用技术并发处理连接。采用epoll+自己实现的简单的事件框架。将epoll中的读、写、关闭、连接都转化为事件,再利用epoll的多路复用特性实现一个ae高性能网络事件处理框架,绝不在I/O上浪费时间。

    在解释I/O多路复用之前,我们先了解下基本I/O操作会经历什么。

    一个基本的网络I/O模型处理get请求时,会经历如下过程。

    1. 服务端bind/listen绑定IP地址并监听指定端口的请求,与客户端建立accept。
    2. 从socket读取请求recv。
    3. 解析客户都安发送的请求parse。
    4. 指向get命令。
    5. send执行相应客户端数据,也就是向socket写回数据。

    其中,bind、accept、recv、parse和send都属于网络I/O处理,而get命令属于键-值数据操作。既然redis是单线程的,那么,最基本的实现就是在一个线程中依次执行上述操作。其中的关键是accept和recv会出现阻塞,当redis监听到一个客户端有连接请求,但一直未能成功建立连接时,会阻塞在accept函数,导致其他客户端无法和redis建立连接。类似地,当redis通过recv函数从一个客户端读取数据时,如果数据一直没有到达,那么redis就会一直阻塞在recv函数。

    "多路"指多个socket连接,"复用"指共同使用一个线程。多路复用主要有select、poll和epoll三种技术。epoll的基本原理是,内核不监视应用程序本身的连接,而是监视应用程序的文件描述符。客户端在运行时会生成具有不同事件类型的套接字。在服务器端,I/O多路复用程序会将消息放入队列,然后通过文件事件分派器将其转发到不同的事件处理器。

    简单来说,在单线程条件下,内核会一直监听socket上的连接请求或者数据请求,一旦有请求到达就交给redis线程处理,这就实现了一个redis线程处理多个I/O流的效果。

    select/epoll提供了基于事件的回调机制,即针对不同事件调用不同的事件处理器。所以,redis一直在处理事件,响应性能得到了提升。redis线程不会阻塞在某一个特定的监听或套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。因此,redis可以同时和多个客户端连接并处理请求,以提升并发能力。

  • 单线程模型

    单线程指redis的网络I/O以及field-value pairs命令读/写是由一个线程来执行的。redis的持久化、集群数据同步,异步删除等操作都是其他线程执行的。
    单线程高性能的原因

    redis选择使用单线程处理命令以及高性能的主要原因如下:

    1. 不会因为创建线程消耗性能。
    2. 避免上下文切换引起的cpu消耗,没有多线程切换的开销。
    3. 避免了线程之间的竞争问题,例如添加锁、释放锁、死锁等。
    4. 代码更清晰。
  • 高效的数据结构

    redis通过一个散列表来保存所有的key-value,散列表的本质就是数组+链表,数组的槽位被叫做哈希桶。每个桶的entry保存指向具体key和value的指针。key是string类型,value的数据类型可以是5种中的任意一种。

    我们可以把redis看作一个全局散列表,而全局散列表的时间复杂度是O(1)。通过计算每个键的哈希值,可以知道对应的哈希桶位置,再通过哈希桶的entry找到对应的数据,这也是redis快的原因之一。

相关推荐
数据智能老司机15 小时前
CockroachDB权威指南——CockroachDB SQL
数据库·分布式·架构
数据智能老司机16 小时前
CockroachDB权威指南——开始使用
数据库·分布式·架构
松果猿16 小时前
空间数据库学习(二)—— PostgreSQL数据库的备份转储和导入恢复
数据库
Kagol16 小时前
macOS 和 Windows 操作系统下如何安装和启动 MySQL / Redis 数据库
redis·后端·mysql
无名之逆16 小时前
Rust 开发提效神器:lombok-macros 宏库
服务器·开发语言·前端·数据库·后端·python·rust
s91236010116 小时前
rust 同时处理多个异步任务
java·数据库·rust
数据智能老司机17 小时前
CockroachDB权威指南——CockroachDB 架构
数据库·分布式·架构
程序猿熊跃晖17 小时前
解决 MyBatis-Plus 中 `update.setProcInsId(null)` 不生效的问题
数据库·tomcat·mybatis
ashane131418 小时前
Redis 哨兵集群(Sentinel)与 Cluster 集群对比
redis