通过Mysql分析Redis的内存快照数据

图片来自pixabay.com的mrgajowy3会员

本文描述了如何将Redis的内存快照数据导出,然后导入到Mysql,通过Mysql实现对Redis的内存数据分析,获取键值总数、内存消耗最大键值等信息。

1. 环境版本

本文的演示环境如下,

  • Mysql 5.7(安装在windows)
  • Python 3.6(安装在linux)
  • Redis 3.2.5(安装在linux)

2. 获取Redis内存快照

登录Redis客户端,执行bgsave命令,此命令将当前Redis的内存快照保存为dump.rdb文件

$ ./redis-3.2.5/bin/redis-cli -h localhost
localhost:6379> bgsave
Background saving started

Redis服务端将显示如下日志,

8961:M 01 Apr 21:12:06.419 * Background saving started by pid 9505
9505:C 01 Apr 21:12:06.440 * DB saved on disk
9505:C 01 Apr 21:12:06.441 * RDB: 0 MB of memory used by copy-on-write
8961:M 01 Apr 21:12:06.533 * Background saving terminated with success

生成的dump.rdb文件位于,

  • 缺省文件位置:./redis-3.2.5/bin/dump.rdb
  • 自定义文件位置:见redis.conf文件中的配置项dbfilename dump.rdb,该配置项可以改变dump.rdb文件的保存位置。

3. 通过rdb工具导出csv格式数据

3.1 安装rdb工具

工具rdb是一个Python工具,需要预先安装Python,然后执行如下命令,

# 创建虚拟环境
python -m venv venv

# 激活虚拟环境
# - 在windows系统
venv\Scripts\activate.bat
# - 在linux系统
source venv\Scripts\activate

# 安装依赖
pip install rdbtools
pip install python-lzf

注1:Windows7上安装rdbtools,会出现如下报错信息。根据提示,rdbtools需要安装Microsoft Visual C++ 14.0。

error: Microsoft Visual C++ 14.0 is required. Get it with "Microsoft Visual C++ Build Tools": https://visualstudio.microsoft.com/downloads/

注2:安装python-lzf,这是由于在下一步解析dump文件时,该工具能够加快解析速度。不安装的话,将会有提示信息:Parsing dump file will be very slow unless you install it。

3.2 导出csv格式数据

在安装rdbtools好了后,执行如下命令,通过rdb工具解析dump文件为csv文件。

rdb -c memory dump.rdb > memory.csv

打开memory.csv文件,可以看到里面内容格式如下,

database,type,key,size_in_bytes,encoding,num_elements,len_largest_element,expiry
0,string,username,72,string,8,8,
0,string,test,48,string,8,8,
......

里面包括了如下的redis键值信息,

  • 数据库
  • 键类型
  • 键名
  • 占用内存空间大小
  • 编码
  • 元素个数
  • 最大元素大小
  • 失效时间

接下来就可以将memory.csv文件导入到Mysql进行下一步的统计分析。

4. 导入csv内存快照数据到Mysql

  1. 打开Mysql客户端,执行如下sql(请预先创建好数据库datareport),创建表memory,
    DROP TABLE IF EXISTS `datareport`.`memory`;
    create table IF NOT EXISTS `datareport`.`memory` (
    `database` int,
    `type` varchar(128),
    `key` varchar(128),
    `size_in_bytes` int,
    `encoding` varchar(128),
    `num_elements` int,
    `len_largest_element` int,
    `expiry` varchar(128),
    KEY `idx_type` (`type`),
    KEY `idx_key` (`key`),
    KEY `idx_size_in_bytes` (`size_in_bytes`),
    KEY `idx_num_elements` (`num_elements`),
    KEY `idx_len_largest_element` (`len_largest_element`)
    );
    
  2. 复制文件memory.csv到C:/ProgramData/MySQL/MySQL Server 5.7/Uploads目录下。
    • 打开复制的memory.csv文件,删除如下第一行表头数据。
    database,type,key,size_in_bytes,encoding,num_elements,len_largest_element,expiry
    
  3. 执行如下sql命令,加载csv数据到表
    LOAD DATA INFILE'C:/ProgramData/MySQL/MySQL Server 5.7/Uploads/memory.csv' replace INTO TABLE `datareport`.`memory` FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' ESCAPED BY '"' LINES TERMINATED BY '\n';
    

    根据数据文件大小和机器配置,该加载过程时间会比较长,150MB的memory.csv文件耗时可能达1个小时。

5. 通过Mysql分析Redis内存数据

数据导入到数据库后,接下来就可以进行一些数据统计分析了,下面给出几个数据统计样例。

  1. 查询key个数
    SELECT COUNT(*) FROM `memory`;
    
  2. 查询总的内存占用
    SELECT SUM(size_in_bytes) FROM `memory`;
    
  3. 查询内存占用最高的前10个key
    SELECT * FROM `memory` ORDER BY size_in_bytes DESC LIMIT 10;
    
  4. 查询成员个数1000个以上的list/hash
    SELECT * FROM `memory` WHERE TYPE='quicklist' AND num_elements > 1000;
    
  5. 查询成员个数最多的前100个hash/set/sortedset
    SELECT * FROM MEMORY WHERE TYPE='hash' ORDER BY num_elements DESC LIMIT 100;
    SELECT * FROM MEMORY WHERE TYPE='set' ORDER BY num_elements DESC LIMIT 100;
    SELECT * FROM MEMORY WHERE TYPE='sortedset' ORDER BY num_elements DESC LIMIT 100;
    
  6. 保存数据到文件中
    SELECT * FROM `memory` ORDER BY size_in_bytes DESC LIMIT 10 INTO OUTFILE 'C:/ProgramData/MySQL/MySQL Server 5.7/Uploads/total.csv' FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' ESCAPED BY '"' LINES TERMINATED BY '\r\n';
    

6. 参考资料

  1. 阿里云:Redis 内存分析方法

Redis缓存组件开发规范

图片来自pixabay.com的katja会员

1. 简介

Redis是业界流行的缓存组件,为了规范Redis缓存的使用,避免落入各种问题陷阱,特此编写了此开发规范。本规范结合实际情况,描述了需要遵守的Redis最佳使用规约,及其供参考的最佳实践,供研发团队在项目开发中使用。

本文将出现如下规约术语,其中根据约束力强弱,规约分别有如下三级,
【强制】必须遵守的规约
【推荐】推荐遵守的规约,若无特殊情况,需要遵守该规约
【参考】参考遵守的规约,团队根据实际情况,可以选择性的遵守

每一个规约,根据情况,将有如下附加说明,

  • 说明:对规约进一步的引申和解释
  • 正例:提供一个正面遵守规约的样例
  • 反例:提供一个规约的反面样例,以及真实的错误案例,提醒误区

2. 规约

2.1 键值设计

  1. 【强制】键的命名使用英文小写和冒号、下划线、数字,其中冒号和下划线不能作为键名的开始和结束,而冒号为命名空间分隔符。不要使用中文和特殊字符作为健名。
    • 正例:"pphh:account:user1"
    • 反例1:"pphh.账号-USER1",该反例使用了点、中文、中划线、英文大写。
    • 反例2:":pphh:account:user1_", 该反例使用了冒号和下划线作为开始和结束
  2. 【推荐】键的命名尽量简单、清晰、易懂,长度尽量控制在32个字符以内,不要超过64个字符。
    • 说明:长的键名不仅消耗内存空间,而且会影响键的搜索查询速度。为了保证键的可读性,也不推荐太短的键名,比如"u1000flw",可以使用"user:1000:followers"来表达。
    • 反例1:"pphh:account:loooooooooooooooooooooooooooooooooooooooooog:name"
  3. 【推荐】键的命名空间分为三级,格式为 {系统简称}:{应用名}:{业务健名},命名空间通过冒号区分,公共键值以common表示。
    • 正例1:"web:home_page:click"
    • 正例2:"common:user_login:verification_code"
    • 反例3:"web_home_page_click"
  4. 【推荐】键的值存储空间大小尽量在10KB以内,不推荐存储大于1MB的值。

  5. 【推荐】对于列表键(Hash、List、Set、Zset),尽量控制元素个数不超过千万数量级,若大于这个数量级,建议通过键值切分列表。

  6. 【推荐】建议List当做队列来使用。

2.2 应用规约

  1. 【推荐】业务应用尽量不在Redis存储持久状态,并且需要考虑和实现键值随时失效时的数据更新方法。

  2. 【推荐】对于键值,建议设置随机失效时间,避免同一时间大量键值失效,从而导致的缓存雪崩问题。

    • 说明:设置合适的随机失效时间,以避免在同一时间大量key失效。
  3. 【推荐】在使用缓存时,关注访问不存在键时导致的缓存穿透问题,关注热key导致的缓存击穿问题。
    • 说明:可以通过监控机制获取当前热key的访问情况,以便改进键值分布,详情见下文的监控告警。
  4. 【强制】禁止使用全表搜索命令,比如keys等命令。
    • 说明:请使用scan实现游标式的遍历,建议通过rename-command将keys命令禁用。
  5. 【推荐】使用scan实现游标式的遍历,实现全表搜索。

  6. 【推荐】对于O(N)时间复杂度的Redis命令,在使用时要预估N的大小,建议N不大于1000。

    • 说明:对于O(N)的Redis命令,当N的数量级不可预知时,则应避免使用。对一个Hash数据执行HGETALL/HKEYS/HVALS命令,通常来说这些命令执行的很快,但如果这个Hash中的元素数量级增大后,耗时就会成倍增长。
  7. 【推荐】键的排序、并集、交集等操作时间复杂度都在O(N),建议这些排序、并集、交集操作放在应用代码里执行。
    • 说明:使用SUNION对两个Set执行并集操作,或使用SORT对List/Set执行排序操作等时,当列表元素很多时,容易导致Redis操作时间很长,形成阻塞。
  8. 【强制】禁止如下命令的使用:flushall,flushdb
    • 说明:键值的删除建议通过scan和del命令相配合的方式删除,建议通过rename-command将flushall和flushdb命令禁用。
  9. 【推荐】推荐使用Redis的批量操作命令,比如MSET/MGET/HMSET/HMGET等等,以取代多次单键操作。
    • 说明:一个MSET批量操作命令可以替代多次SET操作,这可以大大减少维护网络连接和传输数据所消耗的资源和时间。

2.3 服务端配置

  1. 【推荐】搭建Redis集群,保障高可用服务,对热键可以考虑读写分离的架构实现。

  2. 【推荐】选择RDB的数据持久化方式。

    • 说明:Redis支持RDB和AOF两种持久化方式,相比AOF,RDB方式性能影响最小,其保存快照时是通过fork的子线程进行,几乎不影响Redis处理客户端请求,生成的快照文件大小也比AOF方式小,从快照中恢复数据的速度也要更快。RDB的缺点是其快照生成是定期的,在Redis出现问题时会出现数据丢失,这需要业务代码能够进行一定的容错处理(即:Redis缓存不保证数据的一致性)。
    save 900 1     # 每900秒检查一次数据变更情况,如果发生了1次或以上的数据变更,则进行RDB快照保存
    save 300 10    # 每300秒检查一次数据变更情况,如果发生了10次或以上的数据变更,则进行RDB快照保存
    save 60 10000  # 每60秒检查一次数据变更情况,如果发生了100次或以上的数据变更,则进行RDB快照保存
    
  3. 【建议】合理使用Redis database功能
    • 说明:Redis database主要是实现命名空间的功能,实际工作中通过冒号区分命名空间已经足够。Redis的多数据库功能比较简单,不同的数据库支持空间查询、键值清空等操作,并且多个数据库还是单线程处理,相互之间有影响,不同数据库是通过数字区分。
  4. 【推荐】关注网络带宽和延时
    • 说明:相比CPU和内存,网络带宽和延时对Redis服务带来的性能影响更加明显、更加直接。一个4KB的字符串,在10000 q/s的高并发请求量下需要的带宽达312.5 Mbit/s,这个需要千兆带宽的网卡。
  5. 【推荐】设置最大内存,不超过物理内存空间的大小,避免使用swap空间。
    • 说明:默认情况下,在32位OS中,Redis最大使用3GB的内存,在64位OS中则没有限制。当应用使用的虚拟内存空间暂满了物理内存空间,则会开始使用swap空间,可以通过maxmemory来设置Redis的可用最大内存。注意设置时预留一定的主机物理可用内存,以便其它应用运行。
    maxmemory 4000mb
    
  6. 【推荐】设置数据淘汰策略,当Redis的内存空间达到最大可用内存后,则可以根据有效的淘汰策略尝试淘汰数据,释放空间。
    • 说明:默认情况下,Redis不进行数据淘汰。如果当Redis的内存空间达到设置的maxmemory后,若没有配置数据淘汰策略,或者没有数据可以淘汰,那么Redis会对所有写请求返回错误。推荐使用volatile-lru,使用LRU算法进行数据淘汰(淘汰上次使用时间最早的,且使用次数最少的key),其只淘汰设定了有效期的key。
    maxmemory-policy volatile-lru   # 默认是noeviction,即不进行数据淘汰
    
  7. 【强制】开启慢查询记录。
    slowlog-log-slower-than 10000  # 执行时间慢于10毫秒的命令计入慢查询日志
    slowlog-max-len 1024  # 慢查询日志记录长度
    

2.4 客户端配置

  1. 【强制】使用连接池
    • 说明:Redis连接的创建非常耗时,Redis服务的键值查询一般在微妙级别,但是网络连接和传输的耗时在毫秒级别,因此通过连接池来保持连接,可以有效地保证Redis缓存的请求速度,避免频繁连接带来的性能损耗。
  2. 【强制】合理配置连接池
    • 说明:Redis服务端可支持的连接数是有限的,合理配置连接池,可以有效地支持高并发需求场景,也可以在空闲时节约连接数。这些配置包括:最小和最大空闲连接数、空闲连接淘汰策略等。

2.5 安全

  1. 【强制】Redis在设计时要求运行在一个安全的内网,因此禁止暴露Redis服务到外网空间。
    • Redis在设计之初是为了最大的性能考虑,在安全上考虑比较弱,因此不应该暴露Redis服务到外网空间,也就是不应该有外网的客户端访问Redis TCP端口服务。
  2. 【强制】设置用户登录密码

2.6 监控告警

  1. 【强制】定时监控慢查询,对于慢查询操作进行分析并解决,避免再次发生。

  2. 【推荐】定时分析Redis big keys,避免占用空间大的Redis健值,合理拆分。

    • 说明:可以通过redis-cli --bigkeys -i 0.1命令查询大的Redis健值。
  3. 【推荐】定时分析Redis热键,设置合理的过期失效时间和更新时间,设计合理的键值分布。
    • 说明:在Redis 4.0版本以上,可以通过redis-cli --hotkeys命令查询大的Redis热键。
  4. 【推荐】定时分析Redis内存空间使用情况,通过RDB快照获取内存空间,查出占用空间大的键值,并进行拆分修复。

3. 参考资料

  1. Redis官方文档
  2. Redis官网:数据类型简介
  3. Redis官网:性能简介
  4. Redis官网:安全简介
  5. 阿里云Redis开发规范
  6. Redis基础、高级特性与性能调优
  7. 阿里云Redis最佳实践:内存分析方法
  8. 阿里云Redis最佳实践:Redis 4.0 热点Key查询方法

一个JedisConnectionException的异常跟踪调查

图片来自pixabay.com的spencerlikestorun会员

1. 问题缘起

有一个web服务应用一直在稳定运行,直到最近一个月,陆续三次在晚上出现了JedisConnectionException的异常,服务集群几乎在同一时间段都报同一错误,无法正常运行,重启后一切正常。

redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
   at redis.clients.util.Pool.getResource(Pool.java:50)
   at redis.clients.jedis.JedisPool.getResource(JedisPool.java:86)
    …
Caused by: java.util.NoSuchElementException: Timeout waiting for idle object
    at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:449)

2. 现场处理和初步调查结论

  • JedisConnectionException异常所报告的问题是,无法正常获取连接,通过查看redis连接池配置(maxTotal=200)和当时的并发流量(并发数小于5),没有发现不对和特别的地方。
  • 通过netstat查看网络TCP连接,redis连接数也不多(小于20),正常连接中,在redis服务器端可以观察到有连接正在陆续正常地新建和关闭,网络没有问题。
  • 查看zabbix和pinpoint监控,主站应用的CPU使用率有升高,不过在正常范围内,但响应时间有飙高(长达6秒),预计是获取redis连接阻塞的原因(在redis客户端设置阻塞时间为6秒)。
  • 为了能够保证主站恢复使用,调查一段时间后,决定重启应用,然后检查所有服务,一切恢复正常使用。

3. 调查方向

根据现象,确定如下几个调查方向,

  1. 查看redis的访问代码实现,是否有redis连接泄漏?并尝试是否可以本地开发环境重现同样问题。
  2. 查看线上配置,是否配置不正确或者未加载的情况?
  3. 查看网络原因,是否由于因为防火墙导致单机连接有限制?
    • 查看iptables
    • 使用tcpdump查看TCP流量
  4. 查看主站应用的请求访问日志和运行日志,查看异常前后有无特别的请求访问?
    • 请求访问日志 localhost_access_log.2018-12-28
    • 运行日志 catalina.out-20181228

4. 定位问题

通过查看代码时,发现有段代码在获取redis连接后,没有在方法退出时执行close()方法,

public void leakRedisConn() {
  Jedis jedis = null;
    try {
        jedis = jedisPool.getResource();
        jedis.ping();
    } catch (Exception ignored) {
    } finally {
        // 没有归还redis连接到空闲资源池
        // if (jedis != null) jedis.close();
    }
}

查看服务集群的请求访问日志和运行日志,发现在JedisConnectionException异常出现前,都有对该方法的调用,印证此方法和异常的出现有强关联性,因此上述方法应该就是异常发生的罪魁祸首。

该方法在获取连接后,并没有执行close()方法,使得redis连接无法回收到空闲资源池,每一次调用,都会泄漏一个连接,直到整个连接池队列达到上限,从而出现异常。具体原因见对JedisPool的代码分析。

5. 异常出现的原因和JedisPool代码分析

通过分析JedisPool的实现代码,可以看到里面有两个内部对象allObjects和idleObjects,一个是保存着所有连接对象,一个是保存着所有空闲连接对象,

public class JedisPool extends Pool<Jedis> {
  protected GenericObjectPool<T> internalPool;
}

// JedisPool里通过GenericObjectPool维护着所创建的连接池对象,实现Jedis连接的创建、获取和回收,
class GenericObjectPool{
    private final Map<T, PooledObject<T>> allObjects; // 保存着所有连接对象,上限通过maxTotal控制
    private final LinkedBlockingDeque<PooledObject<T>> idleObjects; // 保存着所有空闲连接对象,上限通过maxIdle控制
}

每次创建时,新建的连接会放入allObjects列表中,在close()方法调用时,空闲的连接会存放入idleObjects,以待后续重复利用。

方法jedisPool.getResource()会先尝试从空闲队列中获取连接,若获取不到,则尝试新建一个连接。一旦获取到的连接没有执行close()方法,则无法被回收到空闲队列,于是每一次方法调用,allObjects连接池中会增加一个,一旦数量超过maxTotal,则不再允许创建,请见下面的代码,

class GenericObjectPool{

  public T borrowObject(long borrowMaxWaitMillis) throws Exception {
     ...
     boolean create;
     p = (PooledObject)this.idleObjects.pollFirst();
     if(p == null) {
         p = this.create();
         if(p != null) {
            create = true;
         }
     }
     ...
  }

  private PooledObject<T> create() throws Exception {
        int localMaxTotal = this.getMaxTotal();
        long newCreateCount = this.createCount.incrementAndGet();

        // 当创建的个数大于maxTotal时,则不再允许创建,直接返回null
        if((localMaxTotal <= -1 || newCreateCount <= (long)localMaxTotal) && newCreateCount <= 2147483647L) {
            PooledObject p;
            try {
                p = this.factory.makeObject();
            } catch (Exception var6) {
            }
            ...
            return p;
        } else {
            this.createCount.decrementAndGet();
            return null;
        }
  }

}

注意的是,泄漏的连接在redis服务器端在超时后会被关闭,但在客户端则一直占着位置不会被释放,从而报JedisConnectionException异常,直到重启。

5. 后记

对这个JedisConnectionException的异常泄漏问题解决后,后来想了想,还有两个事情可以总结下,以待未来改进。

5.1 保留问题第一现场

在发生问题时,可以通过如下jmap命令保存下Java内存快照,保留第一现场,这样能够为后续查看发生问题,分析JedisPool对象的内部数据提供方便。

jmap -dump:format=b,file=jmap.dump <pid>

下图是通过Eclipse的MemoryAnalyzer工具分析的JedisPool对象,

jedis连接池

若当时有上面的内存快照,可以看到minIdle/maxIdle的配置情况,也可以看到createCount的变量值,这样会更加容易发现redis泄漏问题。

5.2 一颗老鼠屎,坏了一锅粥

对于Jedis连接池这种全局有限资源,必须严控管理,规范使用,不然一个地方出现泄漏,就会导致整个应用崩溃,本案例是一个活生生的例子。