背景
在开发测试过程中,或排查生产问题时,难免会碰到想查看redis服务中某些key的值是怎么变动的,以便确认数据变更与业务流转是否保持一致。此时,就涉及到命令回看——回看redis中执行的变更命令(数据变更)。
那如何实现命令回看呢?作为一个redis使用人员,最先想到的是monitor命令,通过执行monitor来查看redis服务执行的命令情况。
monitor
通过redis-cli连接某个redis实例(redis实例,即部署redis-server的服务节点,本文下同),执行monitor命令,结果如下图所示:
我们也可将monitor结果输出到指定文件,命令示例如下:
还可根据monitor结果筛选包含指定键/键类型的命令信息,命令示例如下:
上述monitor,能获取到从monitor开始该redis实例中执行的所有命令。如果为集群环境,则需要在每一个可能涉及到键变更的实例中分别执行monitor。如果对指定键或指定键pattern的相关key进行查看,则可先确认key所在的实例信息,然后再对应redis实例上执行monitor。虽然能拿到想要的结果,但线上环境不推荐使用monitor命令,且禁止长时间monitor。切记,线上环境需谨慎执行monitor。
由于monitor对服务性能有明显影响,我们仅能短时间monitor,不能完全覆盖所有场景。那是否有其他解决方案呢?
键事件及redis发布订阅
redis提供了发布订阅功能,那么如果键变更,是否可发布变更消息到某个指定的channel,我们通过订阅channel信息,从而获得指定键的数据变更情况呢?redis是否提供发布键变更消息的功能?这就要提到redis中的事件通知了。
redis对于事件通知描述如下:
上图中定义了事件通知类型,在后文有较详细的说明。既然找到了根据,那就开干吧。
启用配置
由于一般redis应用中无需关注键事件通知,且redis开启事件通知时,会造成资源的消耗,尤其是cpu资源,故redis默认是不开启事件通知。我们首先需要修改redis的notify-keyspace-events的配置值,从而实现当键变更时,发布键事件通知到channel中。
修改之前先确认一下配置值说明。事件通知类型消息解释如下:
事件通知英文名称 | 事件通知名称 | 事件通知说明 | 示例 |
Key-space | 键空间通知 | 从键空间中键(pattern)的角度出发——展示该键(pattern)变更事件 | PUBLISH __keyspace@0__:mykey del |
Key-event | 键事件通知 | 从键事件的角度出发 | PUBLISH __keyevent@0__:del mykey |
对应的事件类型,可查看如下截图:
类字符 | 字符含义 |
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'代表所有事件 |
在对应的redis应用执行config set notify-keyspace-events AKE。如需关注键新增事件或键未命中事件,请在配置值中额外补加对应类字符。
订阅事件通知消息并处理纪录
由于我们更关注某个或某些指定key的变更信息,故仅订阅键空间事件通知。通过redis客户端连接redis,并订阅事件通知消息。命令如下所示:其中keyPattern为键的pattern匹配串。如为固定某个键,则填写该键即可。
PSUBSCRIBE __keyspace@0__:${keyPattern}
订阅接收示例如下图所示,客户端1执行命令:
客户端2订阅消息:
接收并处理事件通知:
从订阅消息中,解析出变更的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文件内容示例:
由于redo log中存在同一个key多次写命令,为了尽量减小aof文件大小,及在重启redis服务时,减少aof加载时间,且 为了尽量保证aof文件占用合理内存,会对aof文件进行rewrite重写,对相同的key,仅保留最新的数据。
那么我们是否可以关闭aof重写,实现保留键变更历史命令呢?答案是当然可以。
通过设置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文件中是有执行时间了吗?这个可以有。
redis 7.0在aof中增加了timestamp,可以根据需要进行开启。但由于是新特性,如果开启,可能不能兼容已有的aof解析器。开启timestamp配置:config set aof-timestamp-enabled yes。与上述处理aof文件逻辑一致,只是需要支持timestamp的aof解析工具进行解析。由于timestamp 并不是每一条命令的属性,而仅是标识aof 文件中该timestamp之后执行的命令。所以需要在解析的时候注意一下。
如下图所示,在aof文件中,增加了#TS:1661161652(timestamp单位:秒):
获取的结果示例如下:
命令 | 命令参数 | 时间 |
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命令及命令方法的说明。
我们可以看到,在server分类下,有monitor名称的命令,其对应执行的命令方法为monitorCommand。我们在执行monitor命令时,会创建CLIENT_MONITOR的客户端。在monitorCommand中,会将该客户端添加到server.monitors链表中,并返回OK。
在redis执行命令,即执行server.c的call方法时,会调用命令的实现方法。然后会判断命令是否为可被monitor记录。
在其中的replicationFeedMonitors方法中,就是执行真正的monitor记录并返回给monitor客户端用于输出显示。
到此,monitor的完整过程就介绍完了。