Redis高级特性与应用——发布订阅、Stream、慢查询、Pipeline、事务、Lua脚本

643 阅读16分钟

ChatGPT Image 2026年1月7日 10_11_39.png

概述

本篇文章概要:

  • 发布订阅
  • Stream
  • 慢查询
  • Pipeline
  • 事务
  • Lua脚本

发布订阅机制

Redis 提供了基于“发布/订阅”模式的消息机制,此种模式下,消息发布者和订阅者不进行直接通信,发布者客户端向指定的频道(Channel)发布消息,订阅该频道的每个客户端都可以收到该消息。

9f6b447fb8024a3595352326d792ba95.png

操作命令

Redis 主要提供了发布消息、订阅频道、取消订阅以及按照模式订阅和取消订阅等命令。

发布消息

publish ${channel} ${message}
  • channel:频道的名称。
  • message:消息内容。

返回值是接收到信息的订阅者数量,如果是 0 说明没有订阅者,这条消息就丢了(再启动订阅者也不会收到)。

image.png

订阅消息

subscribe [${channels}...]

订阅者可以订阅一个或多个频道,如果此时另一个客户端发布一条消息,当前订阅者客户端会收到消息。

image.png

image.png

如果有多个客户端同时订阅了同一个频道,都会收到消息。

查询订阅情况

查询活跃的频道

pubsub channels [pattern]

image.png

pubsub 命令用于查看订阅与发布系统状态,包括活跃的频道(是指当前频道至少有一个订阅者),其中 [pattern] 是可以指定具体的模式,类似于通配符。

查询频道订阅数

pubsub numsub ${channel}

image.png

最后也可以通过 help 看具体的参数运用:

image.png

应用场景和缺点

需要消息解耦又并不关注消息可靠性的地方都可以使用发布订阅模式。

pubsub 的生产者传递过来一个消息,Redis 会直接找到相应的消费者传递过去。如果一个消费者都没有,那么消息直接丢弃。如果开始有三个消费者,一个消费者突然挂掉了,生产者会继续发送消息,另外两个消费者可以持续收到消息。但是挂掉的消费者重新连上的时候,这断连期间生产者发送的消息,对于这个消费者来说就是彻底丢失了。

所以和很多专业的消息队列系统(例如 Kafka、RabbitMQ)相比,Redis 的发布订阅很粗糙,例如无法实现消息堆积和回溯。但胜在足够简单,如果当前场景可以容忍的这些缺点,也不失为一个不错的选择。

正是因为 pubsub 有这些缺点,它的应用场景其实是非常狭窄的。从 Redis 5.0 新增了 Stream 数据结构,这个功能给 Redis 带来了持久化消息队列。

Stream数据结构

什么是Stream

Redis 5.0 最大的新特性就是多出了一个数据结构 Stream,它是一个新的强大的支持多播的可持久化的消息队列,Redis 的作者声明 Redis Stream 的确借鉴了 Kafka 的设计。

78e6d284fc6c4f8ab774287aad02f501.png

Redis Stream 的结构如上图所示,每一个 Stream 都有一个消息链表,将所有加入的消息都串起来,每个消息都有一个唯一的 ID 和对应的内容。消息是持久化的,Redis 重启后内容还在。

  • 每个 Stream 都有唯一的名称,它就是 Redis 的 key,在我们首次使用 xadd 指令追加消息时自动创建。
  • 消息 ID 的形式是 timestampInMillis-sequence,例如 152784688057251527846880572-5,它表示当前的消息在毫米时间戳 15278468805721527846880572 时产生,并且是该毫秒内产生的第 5 条消息。消息 ID 可以由服务器自动生成(* 代表默认自动),也可以由客户端自己指定,但是形式必须是“整数-整数”,而且必须是后面加入的消息的 ID 要大于前面的消息 ID。消息内容就是键值对,形如 hash 结构的键值对。
  • 每个 Stream 都可以挂多个消费组,每个消费组会有个游标 last_delivered_id 在 Stream 数组之上往前移动,表示当前消费组已经消费到哪条消息了。每个消费组都有一个 Stream 内唯一的名称,消费组不会自动创建,它需要单独的指令 xgroup create 进行创建,需要指定从 Stream 的某个消息 ID 开始消费,这个 ID 用来初始化 last_delivered_id 变量。
  • 每个消费组(Consumer Group)的状态都是独立的,相互不受影响。也就是说同一份 Stream 内部的消息会被每个消费组都消费到。
  • 同一个消费组 (Consumer Group) 可以挂载多个消费者 (Consumer),这些消费者之间是竞争关系,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动。每个消费者有一个组内唯一名称。
  • 消费者 (Consumer) 内部会有个状态变量 pending_ids,它记录了当前已经被客户端读取,但是还没有 ack 的消息。如果客户端没有 ack,这个变量里面的消息 ID 会越来越多,一旦某个消息被 ack,它就开始减少。这个 pending_ids 变量在 Redis 官方被称之为 PEL,也就是 Pending Entries List,这是一个很核心的数据结构,它用来确保客户端至少消费了消息一次,而不会在网络传输的中途丢失了没处理。

操作命令

生产者

追加消息

xadd ${stream} * ${key1} ${value1} ${key2} ${value2}

* 号表示服务器自动生成 ID,后面顺序跟着一堆 key/value。

162670595459301626705954593-0 则是生成的消息 ID,由两部分组成:时间戳-序号。时间戳是毫秒级单位,是生成消息的 Redis 服务器时间,它是个 64 位整型。序号是在这个毫秒时间点内的消息序号。它也是个 64 位整型。

为了保证消息是有序的,因此 Redis 生成的 ID 是单调递增有序的。由于 ID 中包含时间戳部分,为了避免服务器时间错误而带来的问题(例如服务器时间延后了),Redis 的每个 Stream 类型数据都维护一个 latest_generated_id 属性,用于记录最后一个消息的 ID。若发现当前时间戳退后(小于 latest_generated_id 所记录的),则采用时间戳不变而序号递增的方案来作为新消息 ID(这也是序号为什么使用 int64 的原因,保证有足够多的的序号),从而保证 ID 的单调递增性质。

强烈建议使用 Redis 的方案生成消息 ID,因为这种“时间戳+序号”的单调递增的 ID 方案,几乎可以满足你全部的需求。但 ID 是支持自定义的。

获取消息列表

xrange ${stream} - +

其中 - 表示最小值,+ 表示最大值。

image.png

或者我们可以指定消息 ID 的列表:

xrange streamtest - 1725881916904-0

获取消息长度

xlen ${stream}

删除 Stream

del ${stream}

xdel 可以删除指定的消息(指定 ID):

xdel ${stream} [${id}...]

消费者

单消费者

虽然 Stream 中有消费者组的概念,但是可以在不定义消费组的情况下进行 Stream 消息的独立消费,当 Stream 没有新消息时,甚至可以阻塞等待。Redis 设计了一个单独的消费指令 xread,可以将 Stream 当成普通的消息队列来使用。使用 xread 时,我们可以完全忽略消费组(Consumer Group)的存在,就好比 Stream 就是一个普通的列表。

xread count ${n} streams ${stream} 0-0

表示从 Stream 头部读取 n 条消息,0-0 指从头开始。

xread count 1 streams stream1 $

$ 代表从尾部读取,上面的意思就是从尾部读取最新的一条消息,此时默认不返回任何消息。应该以阻塞的方式读取尾部最新的一条消息,直到新的消息的到来。

xread block 0 count 1 streams stream1 $

block 后面的数字代表阻塞时间,单位毫秒,0 代表一直阻塞。

一般来说客户端如果想要使用 xread 进行顺序消费,一定要记住当前消费到哪里了,也就是返回的消息 ID。下次继续调用 xread 时,将上次返回的最后一个消息 ID 作为参数传递进去,就可以继续消费后续的消息。

消费组

Stream 通过 xgroup create 指令创建消费组 (Consumer Group),需要传递起始消息 ID 参数用来初始化 last_delivered_id 变量。

0- 表示从头开始消费:

xgroup create stream1 c1 0-0

$ 表示从尾部开始消费,只接受新消息,当前 Stream 消息会全部忽略:

xgroup create stream1 c2 $

可以用 xinfo 命令来看看 stream1 的情况:

xinfo stream stream1

查看 stream1 的消费组的情况:

xinfo groups stream1

有了消费组,自然还需要消费者,Stream 提供了 xreadgroup 指令可以进行消费组的组内消费,需要提供消费组名称、消费者名称和起始消息 ID。

它同 xread 一样,也可以阻塞等待新消息。读到新消息后,对应的消息 ID 就会进入消费者的 PEL(正在处理的消息)结构里,客户端处理完毕后使用 xack 指令通知服务器,本条消息已经处理完毕,该消息 ID 就会从 PEL 中移除。

xreadgroup GROUP c1 consumer1 count 1 streams stream1 >

consumer1 代表消费者的名字。

> 表示从当前消费组的 last_delivered_id 后面开始读,每当消费者读取一条消息,last_delivered_id 变量就会前进。前面我们定义 c1 的时候是从头开始消费的,自然就获得 stream1 中第一条消息再执行一次上面的命令。

自然就读取到了下条消息。我们将 stream1 中的消息读取完,很自然就没有消息可读了。

如果同一个消费组有多个消费者,我们还可以通过 xinfo consumers 指令观察每个消费者的状态:

xinfo consumers stream1 c1

确认一条消息:

xack stream1 c1 1665647371850-0

同时 Stream 还提供了命令 xpending 用来获消费组或消费内消费者的未处理完毕的消息:

xpending stream1 c1

基于Redis实现消息队列

基于 Redis 以上几个特性,我们可以用来实现消息队列。

基于pub/sub

优点:

典型的广播模式,一个消息可以发布到多个消费者;多信道订阅,消费者可以同时订阅多个信道,从而接收多类消息;消息即时发送,消息不用等待消费者读取,消费者会自动接收到信道发布的消息。

缺点:

消息一旦发布,不能接收。换句话就是发布时若客户端不在线,则消息丢失,不能寻回;不能保证每个消费者接收的时间是一致的;若消费者客户端出现消息积压,到一定程度,会被强制断开,导致消息意外丢失。通常发生在消息的生产远大于消费速度时;可见,pub/sub 模式不适合做消息存储,消息积压类的业务,而是擅长处理广播,即时通讯,即时反馈的业务。

需要定义一个类继承 JedisPubSub 类:

/**
 * 基于 pub/sub 的消息中间件的实现
 */
@Component
public class PSVer extends JedisPubSub {

    public final static String RS_PS_MQ_NS = "pbsb:";

    @Autowired
    private JedisPool jedisPool;

    @Override
    public void onMessage(String channel, String message) {
        System.out.println("Accept " + channel + " message:" + message);
    }

    @Override
    public void onSubscribe(String channel, int subscribedChannels) {
        System.out.println("Subscribe " + channel + " count:" + subscribedChannels);
    }

    public void pub(String channel, String message) {
        try (Jedis jedis = jedisPool.getResource()) {
            jedis.publish(RS_PS_MQ_NS + channel, message);
            System.out.println("发布消息到" + RS_PS_MQ_NS + channel + " message=" + message);
        } catch (Exception e) {
            throw new RuntimeException("发布消息失败!");
        }
    }

    public void sub(String... channels) {
        try (Jedis jedis = jedisPool.getResource()) {
            jedis.subscribe(this, channels);
        } catch (Exception e) {
            throw new RuntimeException("订阅频道失败!");
        }
    }
}

单元测试:

@SpringBootTest
public class TestPSVer {

    @Autowired
    private PSVer psVer;

    @Test
    public void testSub() {
        psVer.sub(PSVer.RS_PS_MQ_NS + "psmq", PSVer.RS_PS_MQ_NS + "psmq2");
    }

    @Test
    public void testPub() {
        psVer.pub("psmq", "msgtest");
        psVer.pub("psmq2", "msgtest2");
    }

}

基于Stream

/**
 * 实现消费组消费,不考虑单消费者模式
 */
@Component
public class StreamVer {

    public final static String RS_STREAM_MQ_NS = "rsm:";

    @Autowired
    private JedisPool jedisPool;

    /**
     * 发布消息到Stream
     */
    public StreamEntryID produce(String key, Map<String, String> message) {
        try (Jedis jedis = jedisPool.getResource()) {
            StreamEntryID id = jedis.xadd(RS_STREAM_MQ_NS + key, StreamEntryID.NEW_ENTRY, message);
            System.out.println("发布消息到" + RS_STREAM_MQ_NS + key + " 返回消息id=" + id.toString());
            return id;
        } catch (Exception e) {
            throw new RuntimeException("发布消息失败!");
        }
    }


    /**
     * 创建消费群组,消费群组不可重复创建
     */
    public void createCustomGroup(String key, String groupName, String lastDeliveredId) {
        Jedis jedis = null;
        try {
            StreamEntryID id = null;
            if (lastDeliveredId == null) {
                lastDeliveredId = "0-0";
            }
            id = new StreamEntryID(lastDeliveredId);
            jedis = jedisPool.getResource();
            /*makeStream表示没有时是否自动创建stream,但是如果有,再自动创建会异常*/
            jedis.xgroupCreate(RS_STREAM_MQ_NS + key, groupName, id, false);
            System.out.println("创建消费群组成功:" + groupName);
        } catch (Exception e) {
            throw new RuntimeException("创建消费群组失败!", e);
        } finally {
            jedis.close();
        }
    }


    /**
     * 消息消费
     */
    public List<Map.Entry<String, List<StreamEntry>>> consume(String key, String customerName, String groupName) {
        try (Jedis jedis = jedisPool.getResource()) {
            // 消息消费时的参数
            XReadGroupParams xReadGroupParams = new XReadGroupParams().block(0).count(1);
            Map<String, StreamEntryID> streams = new HashMap<>();
            streams.put(RS_STREAM_MQ_NS + key, StreamEntryID.UNRECEIVED_ENTRY);
            List<Map.Entry<String, List<StreamEntry>>> result = jedis.xreadGroup(groupName, customerName, xReadGroupParams, streams);
            System.out.println(groupName + "从" + RS_STREAM_MQ_NS + key + "接受消息, 返回消息:" + result);
            return result;
        } catch (Exception e) {
            throw new RuntimeException("消息消费失败!", e);
        }
    }

    /**
     * 消息确认
     */
    public void ackMsg(String key, String groupName, StreamEntryID msgId) {
        if (msgId == null) {
            throw new RuntimeException("msgId为空!");
        }
        try (Jedis jedis = jedisPool.getResource()) {
            System.out.println(jedis.xack(key, groupName, msgId));
            System.out.println(RS_STREAM_MQ_NS + key + ",消费群组" + groupName + " 消息已确认");
        } catch (Exception e) {
            throw new RuntimeException("消息确认失败!", e);
        }
    }
    
    /**
     * 检查消费者群组是否存在,辅助方法
     */
    public boolean checkGroup(String key, String groupName) {
        try (Jedis jedis = jedisPool.getResource()) {
            List<StreamGroupInfo> xinfoGroupResult = jedis.xinfoGroup(RS_STREAM_MQ_NS + key);
            for (StreamGroupInfo groupinfo : xinfoGroupResult) {
                if (groupName.equals(groupinfo.getName())) {
                    return true;
                }
            }
            return false;
        } catch (Exception e) {
            throw new RuntimeException("检查消费群组失败!", e);
        }
    }

    public final static int MQ_INFO_CONSUMER = 1;

    public final static int MQ_INFO_GROUP = 2;

    public final static int MQ_INFO_STREAM = 0;

    /**
     * 消息队列信息查看
     */
    public void mqInfo(int type, String key, String groupName) {
        try (Jedis jedis = jedisPool.getResource()) {
            if (type == MQ_INFO_CONSUMER) {
                List<StreamConsumersInfo> xinfoConsumersResult = jedis.xinfoConsumers(RS_STREAM_MQ_NS + key, groupName);
                System.out.println(RS_STREAM_MQ_NS + key + " 消费者信息:" + xinfoConsumersResult);
                for (StreamConsumersInfo consumersinfo : xinfoConsumersResult) {
                    System.out.println("-ConsumerInfo:" + consumersinfo.getConsumerInfo());
                    System.out.println("--Name:" + consumersinfo.getName());
                    System.out.println("--Pending:" + consumersinfo.getPending());
                    System.out.println("--Idle:" + consumersinfo.getIdle());
                }
            } else if (type == MQ_INFO_GROUP) {
                List<StreamGroupInfo> xinfoGroupResult = jedis.xinfoGroup(RS_STREAM_MQ_NS + key);
                System.out.println(RS_STREAM_MQ_NS + key + "消费者群组信息:" + xinfoGroupResult);
                for (StreamGroupInfo groupinfo : xinfoGroupResult) {
                    System.out.println("-GroupInfo:" + groupinfo.getGroupInfo());
                    System.out.println("--Name:" + groupinfo.getName());
                    System.out.println("--Consumers:" + groupinfo.getConsumers());
                    System.out.println("--Pending:" + groupinfo.getPending());
                    System.out.println("--LastDeliveredId:" + groupinfo.getLastDeliveredId());
                }
            } else {
                StreamInfo xinfoStreamResult = jedis.xinfoStream(RS_STREAM_MQ_NS + key);
                System.out.println(RS_STREAM_MQ_NS + key + "队列信息:" + xinfoStreamResult);
                System.out.println("-StreamInfo:" + xinfoStreamResult.getStreamInfo());
                System.out.println("--Length:" + xinfoStreamResult.getLength());
                System.out.println("--RadixTreeKeys:" + xinfoStreamResult.getRadixTreeKeys());
                System.out.println("--RadixTreeNodes():" + xinfoStreamResult.getRadixTreeNodes());
                System.out.println("--Groups:" + xinfoStreamResult.getGroups());
                System.out.println("--LastGeneratedId:" + xinfoStreamResult.getLastGeneratedId());
                System.out.println("--FirstEntry:" + xinfoStreamResult.getFirstEntry());
                System.out.println("--LastEntry:" + xinfoStreamResult.getLastEntry());
            }
        } catch (Exception e) {
            throw new RuntimeException("消息队列信息检索失败!", e);
        }
    }
}

单元测试:

@SpringBootTest
public class TestStreamVer {

    @Autowired
    private StreamVer streamVer;

    private final static String KEY_NAME = "testStream";

    private final static String GROUP_NAME = "testgroup";

    @Test
    public void testProduce() {
        Map<String, String> message = new HashMap<>();
        message.put("name", "lbd");
        message.put("age", "18");
        streamVer.produce(KEY_NAME, new HashMap<>(message));
        streamVer.mqInfo(StreamVer.MQ_INFO_STREAM, KEY_NAME, null);
        streamVer.mqInfo(StreamVer.MQ_INFO_GROUP, KEY_NAME, null);
    }

    @Test
    public void testConsumer() {
        if (!streamVer.checkGroup(KEY_NAME, GROUP_NAME)) {
            streamVer.createCustomGroup(KEY_NAME, GROUP_NAME, null);
        }
        List<Map.Entry<String, List<StreamEntry>>> results = streamVer.consume(KEY_NAME, "testUser", GROUP_NAME);
        streamVer.mqInfo(StreamVer.MQ_INFO_GROUP, KEY_NAME, GROUP_NAME);
        streamVer.mqInfo(StreamVer.MQ_INFO_CONSUMER, KEY_NAME, GROUP_NAME);
        for (Map.Entry<String, List<StreamEntry>> result : results) {
            for (StreamEntry entry : result.getValue()) {
                streamVer.ackMsg(KEY_NAME, GROUP_NAME, entry.getID());
                streamVer.mqInfo(StreamVer.MQ_INFO_GROUP, KEY_NAME, GROUP_NAME);
                streamVer.mqInfo(StreamVer.MQ_INFO_CONSUMER, KEY_NAME, GROUP_NAME);
            }

        }
    }

    @Test
    public void testAck() {
        streamVer.ackMsg(KEY_NAME, GROUP_NAME, null);
        streamVer.mqInfo(StreamVer.MQ_INFO_GROUP, KEY_NAME, GROUP_NAME);
        streamVer.mqInfo(StreamVer.MQ_INFO_CONSUMER, KEY_NAME, GROUP_NAME);
    }
}

基于List——lpush/lpop

此种方法足够简单,消费消息延迟几乎为零,但是需要处理空闲连接的问题。

如果线程一直阻塞在那里,Redis 客户端的连接就成了闲置连接,闲置过久,服务器一般会主动断开连接,减少闲置资源占用,这个时候 blpopbrpop 或抛出异常,所以在编写客户端消费者的时候要小心,如果捕获到异常,还有重试。

其他缺点包括:

做消费者确认 ACK 麻烦,不能保证消费者消费消息后是否成功处理的问题(宕机或处理异常等),通常需要维护一个 Pending 列表,保证消息处理确认;不能做广播模式,如 pub/sub,消息发布/订阅模型。不能重复消费,一旦消费就会被删除。不支持分组消费。

@Component
public class ListVer {

    public final static String RS_LIST_MQ_NS = "rlm:";

    @Autowired
    private JedisPool jedisPool;

    /**
     * 消费者接受消息
     */
    public List<String> get(String key) {
        try (Jedis jedis = jedisPool.getResource()) {
            return jedis.brpop(0, RS_LIST_MQ_NS + key);
        } catch (Exception e) {
            throw new RuntimeException("接受消息失败!");
        }
    }
    
    /**
     * 生产者发送消息
     */
    public void put(String key, String message) {
        try (Jedis jedis = jedisPool.getResource()) {
            jedis.lpush(RS_LIST_MQ_NS + key, message);
        } catch (Exception e) {
            throw new RuntimeException("发送消息失败!");
        }
    }
}

单元测试:

@SpringBootTest
public class TestListVer {

    @Autowired
    private ListVer listVer;

    @Test
    public void testGet() {
        List<String> result = listVer.get("listmq");
        for (String message : result) {
            System.out.println(message);
        }
    }

    @Test
    public void testPut() {
        listVer.put("listmq", "msgtest");
    }
}

基于zset

多用来实现延迟队列,当然也可以实现有序的普通的消息队列,但是消费者无法阻塞的获取消息,只能轮询,不允许重复消息。

@Component
public class ZSetVer {

    public final static String RS_ZS_MQ_NS = "rzsm:";

    @Autowired
    private JedisPool jedisPool;
    
    /**
     * 生产者
     */
    public void produce() {
        try (Jedis jedis = jedisPool.getResource()) {
            for (int i = 0; i < 5; i++) {
                String order_id = "000000000" + i;
                double score = System.currentTimeMillis() + (i * 1000);
                jedis.zadd(RS_ZS_MQ_NS + "orderId", score, order_id);
                System.out.println("生产订单: " + order_id + " 当前时间:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
                System.out.println((3 + i) + "秒后执行");
            }
        } catch (Exception e) {
            throw new RuntimeException("生产消息失败!");
        }

    }
    
    /**
     * 消费者,取订单
     */
    public void consumerDelayMessage() {
        try (Jedis jedis = jedisPool.getResource()) {
            while (true) {
                Set<String> order = jedis.zrangeByScore(RS_ZS_MQ_NS + "orderId", 0, System.currentTimeMillis(), 0, 1);
                if (order == null || order.isEmpty()) {
                    System.out.println("当前没有等待的任务");
                    try {
                        TimeUnit.MILLISECONDS.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    continue;
                }
                String s = order.iterator().next();

                if (jedis.zrem(RS_ZS_MQ_NS + "orderId", s) > 0) {
                    // 业务处理
                    System.out.println(s);
                }
            }

        } catch (Exception e) {
            throw new RuntimeException("消费消息失败!");
        }
    }
}

单元测试:

@SpringBootTest
public class TestZSetVer {

    @Autowired
    private ZSetVer zSetVer;

    @Test
    public void testConsumerDelayMessage() {
        zSetVer.consumerDelayMessage();
    }

    @Test
    public void testProducer() {
        zSetVer.produce();
    }
}

消息队列问题

关于 Redis 是否适合做消息队列,业界一直是有争论的。很多人认为,要使用消息队列,就应该采用 Kafka、RabbitMQ 这些专门面向消息队列场景的软件,而 Redis 更加适合做缓存。

Stream消息太多怎么办?

要是消息积累太多,Stream 的链表岂不是很长,内容会不会爆掉?xdel 指令又不会删除消息,它只是给消息做了个标志位。

Redis 自然考虑到了这一点,所以它提供了一个定长 Stream 功能。在 xadd 的指令提供一个定长长度 maxlen,就可以将老的消息干掉,确保最多不超过指定长度。

消息如果忘记ACK会怎样?

Stream 在每个消费者结构中保存了正在处理中的消息 ID 列表 PEL,如果消费者收到了消息处理完了但是没有回复 ack,就会导致 PEL 列表不断增长,如果有很多消费组的话,那么这个 PEL 占用的内存就会放大。所以消息要尽可能的快速消费并确认。

PEL如何避免消息丢失?

在客户端消费者读取 Stream 消息时,Redis 服务器将消息回复给客户端的过程中,客户端突然断开了连接,消息就丢失了。但是 PEL 里已经保存了发出去的消息 ID。待客户端重新连上之后,可以再次收到 PEL 中的消息 ID 列表。不过此时 xreadgroup 的起始消息 ID 不能为参数 >,而必须是任意有效的消息 ID,一般将参数设为 0-0,表示读取所有的 PEL 消息以及自 last_delivered_id 之后的新消息。

死信问题

如果某个消息,不能被消费者处理,也就是不能被 XACK,这是要长时间处于 Pending 列表中,即使被反复的转移给各个消费者也是如此。此时该消息的 delivery counter(通过 XPENDING 可以查询到)就会累加,当累加到某个我们预设的临界值时,我们就认为是坏消息(也叫死信,DeadLetter,无法投递的消息),由于有了判定条件,我们将坏消息处理掉即可,删除即可。删除一个消息,使用 XDEL 语法。注意,这个命令并没有删除 Pending 中的消息,因此查看Pending,消息还会在,可以在执行执行 XDEL 之后,XACK 这个消息标识其处理完毕。

Stream的高可用

Stream 的高可用是建立主从复制基础上的,它和其它数据结构的复制机制没有区别,也就是说在 Sentinel 和 Cluster 集群环境下 Stream 是可以支持高可用的。不过鉴于 Redis 的指令复制是异步的,在 failover 发生时,Redis 可能会丢失极小部分数据,这点 Redis 的其它数据结构也是一样的。

分区Partition

Redis 的服务器没有原生支持分区能力,如果想要使用分区,那就需要分配多个 Stream,然后在客户端使用一定的策略来生产消息到不同的 Stream。

慢查询

许多存储系统提供慢查询日志帮助开发和运维人员定位系统存在的慢操作。所谓慢查询日志就是系统在命令执行前后计算每条命令的执行时间,当超过预设阈值,就将这条命令的相关信息(例如:发生时间、耗时、命令的详细信息)记录下来,Redis 也提供了类似的功能。

Redis 客户端执行一条命令分为如下 4 个步骤:

发送命令 → 命令排队 → 命令执行 → 返回结果

需要注意,慢查询只统计步骤 3 的时间,所以没有慢查询并不代表客户端没有超时问题。因为有可能是命令的网络问题或者是命令在 Redis 在排队,所以不是说命令执行很慢就说是慢查询,而有可能是网络的问题或者是 Redis 服务非常繁忙(队列等待长)。

慢查询配置

Redis 提供了两种方式进行慢查询的配置:

动态配置

慢查询的阈值默认值是 10ms。

参数 slowlog-log-slower-than 就是时间预设阈值,它的单位是 usus,默认值是 1000010000,假如执行了一条很慢的命令(例如keys *),如果它的执行时间超过了 10000us10000us,也就是 10ms10ms,那么它将被记录在慢查询日志中。

过动态命令修改:

config set slowlog-log-slower-than 20000

若想将配置持久化保存到 redis.conf,要执行:

config rewrite

如果配置 slowlog-log-slower-than=0 表示会记录所有的命令,slowlog-log-slower-than<0对于任何命令都不会进行记录。

配置文件

打开配置文件 redis.conf,就可以看到以下配置:

slowlog-log-slower-than 10000

# 设置慢查询日志最多存储多少条
slowlog-max-len 128

另外 Redis 还提供了 slowlog-max-len 配置来解决存储空间的问题。

实际上 Redis 服务器将所有的慢查询日志保存在服务器状态的 slowlog 链表中(内存列表),slowlog-max-len 就是列表的最大长度(默认 128 条)。当慢查询日志列表被填满后,新的慢查询命令则会继续入队,队列中的第一条数据机会出列。

虽然慢查询日志是存放在 Redis 内存列表中的,但是 Redis 并没有告诉我们这里列表是什么,而是通过一组命令来实现对慢查询日志的访问和管理。并没有说明存放在哪。这个怎么办呢?Redis 提供了一系列的慢查询操作命令让我们可以方便的操作。

操作命令

获取慢查询日志

slowlog get ${n}

n 可以指定查询条数。

每个慢查询日志有 6 个属性组成,分别是慢查询日志的标识 id、发生时间戳、命令耗时(单位微秒)、执行命令和参数,客户端IP + 端口和客户端名称。

获取慢查询日志列表当前的长度:

slowlog len

慢查询日志重置:

slowlog reset

慢查询功能可以有效地帮助我们找到 Redis 可能存在的瓶颈,但在实际使用过程中要注意以下几点:

slowlog-max-len配置建议:

建议调大慢查询列表,记录慢查询时 Redis 会对长命令做截断操作,并不会占用大量内存。增大慢查询列表可以减缓慢查询被剔除的可能,线上生产建议设置为 1000 以上。

slowlog-log-slower-than配置建议:

配置建议:默认值超过 10ms10ms 判定为慢查询,需要根据 Redis 并发量调整该值。

由于 Redis 采用单线程响应命令,对于高流量的场景,如果命令执行时间在 1ms1ms 以上,那么 Redis 最多可支撑 OPS 不到 1000。因此对于高 OPS 场景的 Redis 建议设置为 1ms1ms 或者更低比如 100us100us

慢查询只记录命令执行时间,并不包括命令排队和网络传输时间。因此客户端执行命令的时间会大于命令实际执行时间。因为命令执行排队机制,慢查询会导致其他命令级联阻塞,因此当客户端出现请求超时,需要检查该时间点是否有对应的慢查询,从而分析出是否为慢查询导致的命令级联阻塞。

由于慢查询日志是一个先进先出的队列,也就是说如果慢查询比较多的情况下,可能会丢失部分慢查询命令,为了防止这种情况发生,可以定期执行 slow get 命令将慢查询日志持久化到其他存储中。

Pipeline批处理

前面我们已经说过,Redis 客户端执行一条命令分为如下 4 个部分:1)发送命令,2)命令排队,3)命令执行,4)返回结果。

其中 1 和 4 花费的时间称为 Round Trip Time (RTT,往返时间),也就是数据在网络上传输的时间。

Redis提供了批量操作命令(例如mget、mset等),有效地节约RTT。

但大部分命令是不支持批量操作的,例如要执行n次 hgetall命令,并没有mhgetall命令存在,需要消耗n次RTT。

举例:Redis 的客户端和服务端可能部署在不同的机器上。例如客户端在本地,Redis 服务器在阿里云的广州,两地直线距离约为 800km,那么 1RTT时间=800×2/(300000×2/3)=8ms1 次 RTT 时间 = 800 × 2 / (300000 × 2/3) = 8ms,(光在真空中传输速度为 300,000km/s300,000km/s,这里假设光纤为光速的 2/3)。而 Redis 命令真正执行的时间通常在 usus 级别,所以才会有 Redis 性能瓶颈是网络这样的说法。

Pipeline(流水线)机制能改善上面这类问题,它能将一组 Redis 命令进行组装,通过一次 RTT 传输给 Redis,再将这组 Redis 命令的执行结果按顺序返回给客户端,没有使用 Pipeline 执行了 n 条命令,整个过程需要 n 次 RTT。

Pipeline 并不是什么新的技术或机制,很多技术上都使用过。而且 RTT 在不同网络环境下会有不同,例如同机房和同机器会比较快,跨机房跨地区会比较慢。

redis-cli 的 --pipe 选项实际上就是使用 Pipeline 机制,但绝大部分情况下,我们使用 Java 语言的 Redis 客户端中的 Pipeline 会更多一点。

Pipeline 虽然好用,但是每次 Pipeline 组装的命令个数不能没有节制,否则一次组装 Pipeline 数据量过大,一方面会增加客户端的等待时间,另一方面会造成一定的网络阻塞,可以将一次包含大量命令的 Pipeline 拆分成多次较小的 Pipeline 来完成,比如可以将 Pipeline 的总发送大小控制在内核输入输出缓冲区大小之内或者控制在单个 TCP 报文最大值 1460 字节之内。

内核的输入输出缓冲区大小一般是 4K-8K,不同操作系统会不同(当然也可以配置修改)。

最大传输单元(Maximum Transmission Unit,MTU),这个在以太网中最大值是 1500 Bytes。那为什么单个 TCP 报文最大值是 1460,因为因为还要扣减 20 个 Bytes 的 IP 头和 20 个 Bytes 的 TCP 头,所以是 1460。

同时 Pipeline 只能操作一个 Redis 实例,但是即使在分布式 Redis 场景中,也可以作为批量操作的重要优化手段。

示例代码:

@Component
public class RedisPipeline {

    @Autowired
    private JedisPool jedisPool;

    public List<Object> plGet(List<String> keys) {
        try (Jedis jedis = jedisPool.getResource()) {
            Pipeline pipeline = jedis.pipelined();
            // 开启事务
            pipeline.multi();
            // 提交事务
            pipeline.exec();
            for (String key : keys) {
                pipeline.get(key);
            }
            return pipelined.syncAndReturnAll(); // 这里只会向redis发送一次
        } catch (Exception e) {
            throw new RuntimeException("执行Pipeline获取失败!", e);
        }
    }

    public void plSet(List<String> keys, List<String> values) {
        if (keys.size() != values.size()) {
            throw new RuntimeException("key和value个数不匹配!");
        }
        try (Jedis jedis = jedisPool.getResource()) {
            Pipeline pipeline = jedis.pipelined();
            for (int i = 0; i < keys.size(); i++) {
                pipeline.set(keys.get(i), values.get(i));
            }
            pipeline.sync();
        } catch (Exception e) {
            throw new RuntimeException("执行Pipeline设值失败!", e);
        }
    }
}

只需要获取 Pipeline 对象即可,后续的操作和之前的都一样。

Redis的弱事务

Redis 提供了简单的事务功能,将一组需要一起执行的命令放到 multiexec 两个命令之间。multi 命令代表事务开始,exec 命令代表事务结束。另外 discard 命令是回滚。

multi

sadd A b
sadd B a

exec

image.png

可以看到 sadd 命令此时的返回结果是 QUEUED,代表命令并没有真正执行,而是暂时保存在 Redis 中的一个缓存队列(所以 discard 也只是丢弃这个缓存队列中的未执行命令,并不会回滚已经操作过的数据,这一点要和关系型数据库的 Rollback 操作区分开)。

要注意 Redis 的事务功能很弱。在事务回滚机制上,Redis 能对基本的语法错误进行判断。如果事务中的命令出现错误,Redis 的处理机制也不尽相同。Redis 并不支持回滚功能,开发人员需要自己修复这类问题。

事务是 Redis 实现在服务器端的行为,用户执行 MULTI 命令时,服务器会将对应这个用户的客户端对象设置为一个特殊的状态,在这个状态下后续用户执行的查询命令不会被真的执行,而是被服务器缓存起来,直到用户执行 EXEC 命令为止,服务器会将这个用户对应的客户端对象中缓存的命令按照提交的顺序依次执行。

有些应用场景需要在事务之前,确保事务中的 key 没有被其他客户端修改过,才执行事务,否则不执行(类似乐观锁)。Redis 提供了 watch 命令来解决这类问题。

客户端 1:

347a34b954f04f0296cd36dd6deb138b.png

客户端 2:

5faebac992e6406999bb0ad61a242bfd.png

客户端 1 继续:

bc340c7fd6a74973a6f8d78e71d2e0db.png

可以看到客户端 1 在执行multi之后执行了 watch 命令,客户端 2在客户端 1执行 exec 之前修改了 key 值,造成客户端 1 事务没有执行(exec 结果为 nil)。

PipeLine 看起来和事务很类似,感觉都是一批批处理,但两者还是有很大的区别。简单来说。

  1. pipeline 是客户端的行为,对于服务器来说是透明的,可以认为服务器无法区分客户端发送来的查询命令是以普通命令的形式还是以 pipeline 的形式发送到服务器的;

  2. 而事务则是实现在服务器端的行为,用户执行 MULTI 命令时,服务器会将对应这个用户的客户端对象设置为一个特殊的状态,在这个状态下后续用户执行的查询命令不会被真的执行,而是被服务器缓存起来,直到用户执行 EXEC 命令为止,服务器会将这个用户对应的客户端对象中缓存的命令按照提交的顺序依次执行。

  3. 应用 pipeline 可以提服务器的吞吐能力,并提高 Redis 处理查询请求的能力。

但是这里存在一个问题,当通过 pipeline 提交的查询命令数据较少,可以被内核缓冲区所容纳时,Redis 可以保证这些命令执行的原子性。然而一旦数据量过大,超过了内核缓冲区的接收大小,那么命令的执行将会被打断,原子性也就无法得到保证。因此 pipeline 只是一种提升服务器吞吐能力的机制,如果想要命令以事务的方式原子性的被执行,还是需要事务机制,或者使用更高级的脚本功能以及模块功能。

  1. 可以将事务和 pipeline 结合起来使用,减少事务的命令在网络上的传输时间,将多次网络 I/O 缩减为一次网络 I/O。

Redis 提供了简单的事务,之所以说它简单,主要是因为它不支持事务中的回滚特性,同时无法实现命令之间的逻辑关系计算,当然也体现了 Redis 的“keep it simple”的特性。

集成Lua脚本

Redis 也是支持 Lua 脚本的。

eval命令

eval 命令调用如下:

eval script numkeys key [keys...] arg [args...]
  • script:是一段 Lua 脚本程序,它会被运行在 Redis 服务器上下文中,这段脚本不必定义为一个 Lua 函数。
  • numkeys:用于指定键名参数的个数。
  • key [keys...]:从 eval 的第三个参数开始算起,使用了 numkeys 个键,表示在脚本中所用到的那些 Redis 键,这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,用 1 为基址的形式访问(KEYS[1]KEYS[2]...)。
  • arg [arg...]:可以在 Lua 中通过全局变量 ARGV 数组访问,访问的形式和 KEYS 变量类似(ARGV[1]ARGV[2]...)。

示例:

eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second

如何用 Lua 调用 Redis 的命令呢?这里我们主要记住 call 命令即可:

eval "return redis.call('mset', KEYS[1], ARGV[1], KEYS[2], ARGV[2])" 2 key1 key2 first second

但是 eval 命令要求你在每次执行脚本的时候都发送一次脚本,所以 Redis 有一个内部的缓存机制,因此它不会每次都重新编译脚本,不过在很多场合,付出无谓的带宽来传送脚本主体并不是最佳选择。

为了减少带宽的消耗,Redis 提供了 evalsha 命令,它的作用和 EVAL 一样,都用于对脚本求值,但它接受的第一个参数不是脚本,而是脚本的 SHA1 摘要。

这里就需要借助 script 命令。

  • script flush:清除所有脚本缓存。
  • script exists:根据给定的脚本校验,检查指定的脚本是否存在于脚本缓存。
  • script load:将一个脚本装入脚本缓存,返回 SHA1 摘要,但并不立即运行它。
  • script kill:杀死当前正在运行的脚本。

这里的 script load 命令就可以用来生成脚本的 SHA1 摘要。

script load "return redis.call('set', KEYS[1], ARGV[1])"

image.png

然后就可以执行这个脚本:

evalsha "c686f316aaf1eb01d5a4de1b0b63cd233010e63d" 1 key1 testscript

redis-cli执行脚本

可以使用 redis-cli 命令直接执行脚本,这里我们直接新建一个 Lua 脚本文件,用来获取刚刚存入 Redis 的 key1 的值。编写 test.lua 文件:

local value = redis.call('get', 'key1')
return value

然后执行:

./redis-cli -p 6379 --eval ../scripts/test.lua

# 或者
./redis-cli -p 6379 script load "$(cat ../scripts/test.lua)"

Redis结合Lua实现限流

-- java端送入 3 个参数 1 个 key 2 个 param (string)
-- limitKey redis 中 key 的值
local key = KEYS[1];
-- limit(次数)
local times = ARGV[1];
-- expire(秒S)
local expire = ARGV[2];

-- 对key-value中的 value + 1 的操作  返回一个结果
local afterVal = redis.call('incr', key);

if afterVal == 1 then
    -- 第一次 失效时间(1S)  TLL 1S
    redis.call('expire', key, tonumber(expire))
    return 1; --第一次不会进行限制
end

-- 不是第一次且超出限制次数了
if afterVal > tonumber(times) then
    return 0;
end

return 1;