背景

在开发测试过程中,或排查生产问题时,难免会碰到想查看redis服务中某些key的值是怎么变动的,以便确认数据变更与业务流转是否保持一致。此时,就涉及到命令回看——回看redis中执行的变更命令(数据变更)。

那如何实现命令回看呢?作为一个redis使用人员,最先想到的是monitor命令,通过执行monitor来查看redis服务执行的命令情况。

monitor

通过redis-cli连接某个redis实例(redis实例,即部署redis-server的服务节点,本文下同),执行monitor命令,结果如下图所示:

bloomfilter redis 命令 redis monitor命令_缓存

我们也可将monitor结果输出到指定文件,命令示例如下:

bloomfilter redis 命令 redis monitor命令_redis_02

还可根据monitor结果筛选包含指定键/键类型的命令信息,命令示例如下:

bloomfilter redis 命令 redis monitor命令_redis_03

上述monitor,能获取到从monitor开始该redis实例中执行的所有命令。如果为集群环境,则需要在每一个可能涉及到键变更的实例中分别执行monitor。如果对指定键或指定键pattern的相关key进行查看,则可先确认key所在的实例信息,然后再对应redis实例上执行monitor。虽然能拿到想要的结果,但线上环境不推荐使用monitor命令,且禁止长时间monitor。切记,线上环境需谨慎执行monitor

由于monitor对服务性能有明显影响,我们仅能短时间monitor,不能完全覆盖所有场景。那是否有其他解决方案呢?

键事件及redis发布订阅

redis提供了发布订阅功能,那么如果键变更,是否可发布变更消息到某个指定的channel,我们通过订阅channel信息,从而获得指定键的数据变更情况呢?redis是否提供发布键变更消息的功能?这就要提到redis中的事件通知了。

redis对于事件通知描述如下:

bloomfilter redis 命令 redis monitor命令_redis_04

上图中定义了事件通知类型,在后文有较详细的说明。既然找到了根据,那就开干吧。

启用配置

由于一般redis应用中无需关注键事件通知,且redis开启事件通知时,会造成资源的消耗,尤其是cpu资源,故redis默认是不开启事件通知。我们首先需要修改redis的notify-keyspace-events的配置值,从而实现当键变更时,发布键事件通知到channel中。

修改之前先确认一下配置值说明。事件通知类型消息解释如下:

事件通知英文名称

事件通知名称

事件通知说明

示例

Key-space 
notification

键空间通知

从键空间中键(pattern)的角度出发——展示该键(pattern)变更事件

PUBLISH __keyspace@0__:mykey del

Key-event 
notification

键事件通知

从键事件的角度出发
——展示键事件类型
对应变更的key

PUBLISH __keyevent@0__:del mykey

对应的事件类型,可查看如下截图:

bloomfilter redis 命令 redis monitor命令_缓存_05

类字符

字符含义

K

键空间通知

E

键事件通知

g

通用命令(非指定类型),如 DEL, EXPIRE, RENAME,...

$

String类型命令

l

List类型命令

s

set类型命令

h

Hash类型命令

z

Sorted set类型命令

x

过期事件(每次键过期产生该事件)

e

淘汰事件(最大内存时键淘汰产生该事件)

n

键新增事件(注:不包括在A字符类中) (redis 7.0新增)

t

Stream类型命令

d

Module 键类型事件

m

键未命中事件(注:不包括在A字符类中)

A

g$lshzxetd的别名,所以'AKE'代表所有事件
(除不包含在A类中的独有特性键未命中事件和键新增事件)

在对应的redis应用执行config set notify-keyspace-events AKE。如需关注键新增事件或键未命中事件,请在配置值中额外补加对应类字符。

订阅事件通知消息并处理纪录

由于我们更关注某个或某些指定key的变更信息,故仅订阅键空间事件通知。通过redis客户端连接redis,并订阅事件通知消息。命令如下所示:其中keyPattern为键的pattern匹配串。如为固定某个键,则填写该键即可。

PSUBSCRIBE __keyspace@0__:${keyPattern}

订阅接收示例如下图所示,客户端1执行命令:

bloomfilter redis 命令 redis monitor命令_数据库_06

客户端2订阅消息:

bloomfilter redis 命令 redis monitor命令_数据库_07

接收并处理事件通知:

从订阅消息中,解析出变更的key及变更事件类型(set/expired等),根据变更事件类型,制定不同的处理方式。

  • 新建事件:获取key,执行查询命令获取当前key的value值,并根据接收到订阅消息的时间近似key新增的时间,然后保存记录。
  • 更新事件:获取key,执行查询命令获取当前key的value值,并根据接收到订阅消息的时间近似key变更的时间,然后保存记录。
  • 删除事件:获取key,并根据接收到订阅消息的时间近似key删除的时间,然后保存记录。

停止订阅及停用配置

在不需要关注键变更事件后,停止订阅,并记得停用配置,以便减少资源消耗。在对应的redis应用执行,停用配置命令:config set notify-keyspace-events ''

该方案问题点分析:

影响

操作复杂度

准确度

占用资源,尤其cpu资源

操作步骤较多,但操作较简单

如果键短时间变更频繁,由于变更后数据为异步获取,
存在获取变更后数据不准确的场景

aof文件

查看命令执行历史,一般的存储服务会有log日志,那么redis作为一种支持数据持久化的数据服务,必然也有持久化文件,我们肯定就会想到redis的aof文件。为了避免数据丢失,redis有持久化策略,且当前普遍使用的redis版本一般会采用开启aof持久化配置。如果redis服务,不用做持久化,或仅开启了rdb持久化,则此方式不适用。redis在开启aof持久化时,会保存redo log到aof文件中。

aof文件内容示例:

bloomfilter redis 命令 redis monitor命令_java_08

由于redo log中存在同一个key多次写命令,为了尽量减小aof文件大小,及在重启redis服务时,减少aof加载时间,且 为了尽量保证aof文件占用合理内存,会对aof文件进行rewrite重写,对相同的key,仅保留最新的数据。

那么我们是否可以关闭aof重写,实现保留键变更历史命令呢?答案是当然可以。

bloomfilter redis 命令 redis monitor命令_redis_09

通过设置aof重写配置(禁用aof重写)config set auto-aof-rewrite-percentage 0。禁用上述配置后,还需要保证不调用redis服务的BGREWRITEAOF命令,以彻底规避aof文件重写。在需要关注key变更之前,需要先关闭了aof文件重写配置。在需要查看key变更时,将aof文件导出,通过流行或自建的aof文件分析工具,即可将键变更记录轻松拿到。在完成之后,记得将aof文件重写记得打开,以免aof文件过大,导致后期使用(迁移、重启)消耗过多时间。

获取的结果示例如下:

命令

命令参数

set

key value

del

key

这时,不免发现一个问题,我的键变更命令是什么时候执行的?你这个记录里边没有呀?是的,很遗憾,redis 7.0之前的aof文件中并未存储命令执行时间,所以能拿到和提供的也就这么多了。

那么7.0及之后版本的redis aof文件中是有执行时间了吗?这个可以有。

bloomfilter redis 命令 redis monitor命令_java_10

redis 7.0在aof中增加了timestamp,可以根据需要进行开启。但由于是新特性,如果开启,可能不能兼容已有的aof解析器。开启timestamp配置:config set aof-timestamp-enabled yes。与上述处理aof文件逻辑一致,只是需要支持timestamp的aof解析工具进行解析。由于timestamp 并不是每一条命令的属性,而仅是标识aof 文件中该timestamp之后执行的命令。所以需要在解析的时候注意一下。

如下图所示,在aof文件中,增加了#TS:1661161652(timestamp单位:秒):

bloomfilter redis 命令 redis monitor命令_开发语言_11

获取的结果示例如下:

命令

命令参数

时间

set

key1 value

1661161652

del

key2

1661161652

expire

key3 ttl

1661161653

mset

key4 field value

1661161653

del

key5

1661161658

java 解析aof文件示例如下:

public class RedisKeyChangeTest{
 
    @Test
    public void scanAof(){
        String localFilePath = "/data/redis.aof"; //aof file path
        long currentLine = 1;
        try (FileReader fileReader = new FileReader(new File(localFilePath));
            BufferedReader bufferedReader = new BufferedReader(fileReader);){
            List<RedisKeyUpdateRecord> redisRecordList = new ArrayList<>();
            List<RedisKeyUpdateRecord> tempRedisRecords = new ArrayList<>();
            String str;
            while((str = bufferedReader.readLine()) != null){
                currentLine++;
                if(str.startsWith("*")){
                    RedisKeyUpdateRecord redisRecord = new RedisKeyUpdateRecord();
                    StringBuffer values = new StringBuffer();
                    String s = str.replaceFirst("\\*", "");
                    Integer paramNum = Integer.valueOf(s);
                    for (int i = 0; i < 2* paramNum; i++){
                        String s1 = bufferedReader.readLine();
                        currentLine++;
                        if(i % 2 == 0){
                            continue;
                        }
                        if(i == 1){
                            redisRecord.setCommand(s1);
                        }else if(i == 3){
                            redisRecord.setKey(s1);
                        }else{
                            if(i == 5){
                                values.append(s1);
                            }else{
                                values.append(" ");
                                values.append(s1);
                            }
                        }
                    }
                    redisRecord.setValue(values.toString());
                    redisRecordList.add(redisRecord);
                    tempRedisRecords.add(redisRecord);
                }
                if(str.startsWith("#TS:")){
                    String timestamp = str.replace("#TS:", "");
                    Long updateTime = Long.valueOf(timestamp);
                    Timestamp time = new Timestamp(updateTime * 1000);
                    tempRedisRecords.forEach(redisRecord -> redisRecord.setUpdateTime(time));
                    tempRedisRecords.clear();
                    log.info("save redis key record, currentLine:{}", currentLine);
                }
            }
        }catch (Exception e){
            log.error("save redis key record error, execute currentLine:{}", currentLine, e.getMessage());
        }
    }
 
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    class RedisKeyUpdateRecord {
        /**
         * 命令
         */
        private String command;
        /**
         * key
         */
        private String key;
        /**
         * value
         */
        private String value;
        /**
         * 更新时间
         */
        private Timestamp updateTime;
    }
}

方案总结对比

以上列举了几种能获取到redis命令执行情况的方法,方法各有利弊,可根据实际使用场景,进行评估选取。redis在查看历史命令执行情况方面并没有提供强有力的支撑,这点就不要强求了,相信随着redis的发展和社区的壮大,redis会越来越强大。

序号

方案

说明

影响

1

monitor

1. 键都在同一个redis实例时,操作简单;

1. 对redis应用性能(响应)有明显影响; 

2. 键跨redis实例时,操作较复杂,且需分别整理结果;

3. 能查看命令类型最多(包含查询);

2. 线上环境操作需谨慎,且不能长时间执行;

4. 命令执行时间最精确;

2

键事件通知

1. 操作较复杂,订阅键通知消息;

 

对redis应用cpu资源占用
有一定影响

2. 需自己实现消息解析和处理,尤其在键值变更时,需要自己去获取变更后的值;

3. 单个键频繁变动时,键值存在获取不准的问题,且以消息接收时间作为命令执行时间存在一定时间滞后;

4. 仅能获取写命令;

3

aof文件(redis7.0-)

1. 操作较简单,解析aof文件即可,但无命令执行时间;

对服务影响较小

2. 如果键跨redis实例,需要在相关redis实例分别获取aof解析;

3. 仅能获取写命令;

4

aof文件(redis7.0+)

1. 操作较复杂,解析aof文件即可,并以aof文件中时记录的间戳,作为命令执行时间;

对服务影响较小

2. 如果键跨redis实例,需要在相关redis实例分别获取aof解析;

3. 仅能获取写命令;

拓展

上文中提到了redis的monitor,我们接下来扩展介绍一下。

monitor简介:

monitor是redis的一个调试命令,从redis 1.0.0即支持。monitor可获取到服务器处理的每个命令信息,能够帮助我们更好的了解redis做了什么。出于安全考虑,monitor不记录管理命令,并隐藏命令中的敏感数据。运行monitor会降低redis的处理能力,这点在使用时需要额外注意。

monitor原理:

如下介绍以redis 7.0版本源码为例(为方便展示,部分代码做了精简)。monitor功能变更很小,其他版本源码相差不大。

在commands.c中,有结构为redisCommand的数组redisCommandTable,其中有关于redis命令及命令方法的说明。

bloomfilter redis 命令 redis monitor命令_redis_12

我们可以看到,在server分类下,有monitor名称的命令,其对应执行的命令方法为monitorCommand。我们在执行monitor命令时,会创建CLIENT_MONITOR的客户端。在monitorCommand中,会将该客户端添加到server.monitors链表中,并返回OK。

bloomfilter redis 命令 redis monitor命令_java_13

在redis执行命令,即执行server.c的call方法时,会调用命令的实现方法。然后会判断命令是否为可被monitor记录。

bloomfilter redis 命令 redis monitor命令_缓存_14

在其中的replicationFeedMonitors方法中,就是执行真正的monitor记录并返回给monitor客户端用于输出显示。

bloomfilter redis 命令 redis monitor命令_缓存_15

到此,monitor的完整过程就介绍完了。