传统缓存的问题

传统缓存策略是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库:

  1. 请求经过Tomcat处理,性能成为整个系统的瓶颈
  2. Redis缓存失效时,对数据库产生冲击

多级缓存就是充分利用请求处理每个缓环节,分别添加缓存,减轻Tomcat压力,提升服务性能。

缓存分类

  1. 分布式缓存比如Redis:
    • 优点: 存储容量更大,可靠性更好,可以在集群间共享
    • 缺点: 访问缓存有网络开销
    • 场景: 缓存数据量较大,可靠性要求较高,需要在集群间共享
  2. 进程本地缓存,比如HashMap、GuavaCache:
    • 优点: 读取本地内存,没有网络开销,速度更快
    • 缺点: 存储容量有限,可靠性较低,无法共享
    • 场景: 性能要求较高,缓存数据量较小

multicache

Caffeine

1
2
3
4
5
6
7
8
9
Cache<String, String> cache = Caffeine.newBuilder().build();
cache.put("key", "value")
// 取数据,如果没有则返回NULL,使用较少
String key = cache.getIfPresent("key");

// 取数据,如果没有则使用第二个函数,可以查询数据库
String defaultKey = cache.get("key", key->{
// 写查询数据库代码
});

缓存驱逐策略(三种)

  1. 基于容量: 设置缓存数量上限
    Cache<String, String> cache = Caffeine.newBuilder().maximumSize(1).build(); 设置缓存大小上限为1
  2. 基于时间: 设置缓存的有效时间
    Cache<String, String> cache = Caffeine.newBuilder().expireAfterWrite(Duration.ofSeconds(10)).build(); 设置缓存有效期为10秒,从最后一次写入开始计时
  3. 基于引用: 设置缓存为软引用或弱引用,利用GC来回收缓存数据,性能较差,不建议使用
    • 默认情况下,当一个缓存元素过期时,Caffeine不会自动立即清理和驱逐,而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐。

冷启动与缓存预热

  • 冷启动: 服务刚刚启动时,Redis没有缓存,如果数据都在第一次查询时添加缓存,可能会给数据库带来较大的压力
  • 缓存预热: 实际开发中,可以利用大数据统计用户访问的热点数据,项目启动时将这些热点数据提前查询并保存到Redis中。如果数据量较少,就直接在启动时将所有的数据都放入Redis

缓存同步策略

同步策略 设置有效期 同步双写 异步通知
含义 给缓存设置有效期,到期自动删除,再次查询时更新 修改数据库的同时,直接修改缓存(使用同一个事务实现) 修改数据库时发送事件通知,相关服务监听通知后修改缓存数据 (常用) (MQ, Canal)
优点 简单,方便 时效性强,缓存和数据库强一致性 低耦合,可以同时通知多个缓存服务
缺点 时效性差,缓存过期之前可能数据不一致 有代码侵入风险,耦合度高 时效性一般,可能存在中间不一致状态
使用场景 更新频率较低,适合时效性要求低的业务 对一致性、时效性要求较高的缓存数据 时效性要求一般,有多个服务需要同步

基于MQ实现异步通知

MQ

基于Canal实现异步通知

Canal

  • 基于Canal,比MQ更好一些,时效性更强,监听效率更高,基于主从同步实现
  • Canal把自己伪装成MySQL的一个slave,监听masterbinlog变化,再把得到的变化信息通知给Canal客户端,完成对其他数据库的同步

Redis设计

key设计

  1. 遵循基本格式: [业务名称]:[数据名]:[id]
  2. 长度不要超过44Bytes
  3. 不包含特殊字符
  • 比如登录业务,保存用户信息,设计key就是: login:user:10
  • 长度越小占用的空间越少,所以key满足可读性,应该尽可能短

好处:

  1. 可读性强
  2. 避免key冲突
  3. 方便管理
  4. 更节省内存,keystring,底层编码包含int、embstr、raw,其中embstr是一块连续空间,占用空间更少,更加紧凑。但是embstr需要在key小于44B使用

value设计

  • BigKey,通常以Key的大小和Key中成员的数量来总和判定,比如:
    • Key本身数据量过大,一个String类型的Key,它的值为5MB
    • Key中的成员数目过多,一个ZSET类型的Key,它的成员数量为10000个
    • Key中的成员数据量过大,一个Hash类型的Key,成员数量虽然只有1000个,但是这些Value的总大小为100MB
  • BigKey危害:

    1. 网络阻塞: 对BigKey读请求,少量的QPS就会导致带宽使用率被占满,导致Redis实例,乃至物理机变慢
    2. 数据倾斜: BigKey所在的Redis实例内存使用率远超其他实例,无法使数据分片的内存资源达到均衡
    3. Redis阻塞: 对元素较多的hash, list, zset等做运算会耗时较久,使主线程阻塞
    4. CPU压力: 对BigKey的数据序列化和反序列化都会导致CPU的使用率飙升,影响Redis实例和本机其他应用
  • 如何发现BigKey:

    1. redis-cli --bigkeys: 利用redis-cli提供的--bigkeys,分析所有的key,返回Key整体统计信息与每个数据最大的Big Key
    2. scan扫描: 自己编程利用scan扫描Redis中所有的Key,利用strlen, hlen等命令判断长度,不建议使用memory usage,因为这个命令对CPU占用较高
    3. 第三方工具,比如Redis-Rdb-Tools分析RDB快照,全面分析内存使用
    4. 网络监控: 自定义工具,监控进入Redis网络数据,超出预警值主动告警
  • 推荐做法
    1. 单个keyvalue小于10KB
    2. 对于集合类型的Key,建议元素数量小于1000
    3. 使用unlink异步删除BIGKEY

恰当的数据类型

存储一个User对象,有三种方法:

  1. Json字符串

    user:1{"name": "Jack", "age": 21}
    • 优点: 实现简单粗暴
    • 缺点: 数据耦合不够灵活
  2. 字段打散

    user:1:nameJack
    user:1:age21
    • 优点: 可以灵活访问对象任意字段
    • 缺点: 占用空间大,没有办法做统一控制
  3. hash

    user:1 nameage
    jack21
    • 优点: 底层使用ziplist,占用空间小,可以灵活访问对象的任意字段
    • 缺点: 代码相对复杂
  • 假如有hash类型的key,其中有100万对field, value,其中field是自增id,这个key有什么问题,如何优化?
    • 存在的问题:
      1. hashentry数量超过500时,会使用哈希表,而不是ZipList,内存占用较多
    • 优化:
      1. 可以通过hash-max-ziplist-entries配置entry上限,但是如果entry过多就会导致BigKey问题
      2. 拆分为string类型: 但是string底层没有太多内存优化,内存占用较多;想要批量获取这些数据比较麻烦
      3. 拆分为小的hash,将id/100作为key,将id % 100作为field,这样每一百个元素就是一个hash
  • 推荐做法
    1. 合理拆分数据,拒绝BigKey
    2. 选择合适的数据结构
    3. Hash结构的entry数量不要超过1000
    4. 设置合理的超时时间

批处理

  • Redis的处理速度非常快,消耗的时间绝大部分都是在网络传输中消耗。

  • 所以可以一次性传输多条数据,但是不能传输太多命令,否则这样单次命令占用的带宽过多,会导致网络阻塞

  • mset只能处理string类型的,hmset, sadd都只能处理相同的key,所以有缺陷,因此需要实现pipeline

  • pipeline可以处理复杂类型,但是pipeline的多个命令之间不具备原子性

  • 如果mset或者pipeline这样的批处理需要在一次请求中携带多条命令,并且此时Redis是一个集群,那么批处理的多个key必须在同一个slot中,否则执行失败。

串行命令 串行slot 并行slot hash_tag
实现思路 for循环遍历,依次执行每个命令 在客户端计算每个key的slot,将slot一致分为一组,每组都利用pipeline批处理,串行执行各组命令 在客户算计算每个key的slot,每组都利用pipeline批处理,并行执行各组命令 将所有的key都设置相同的hash_tag,则所有key的slot都一定相同
耗时 N次网络耗时+N次命令耗时 m次网络耗时+N次命令耗时,m = key的slot个数 1次网络耗时+N次命令耗时 1次网络耗时+N次命令耗时
优点 实现简单 耗时较短 耗时非常短 耗时非常短、实现简单
缺点 耗时非常久 实现稍复杂,slot越多,耗时越久 实现复杂(用的较多) 容易出现数据倾斜(所以不怎么用,虽然性能好)

持久化配置

  • 保证数据安全,但是会带来额外的开销
  • 推荐做法
    1. 用来做缓存的Redis实例尽量不要开启持久化功能,放在一个单独实例里面,就不要开启持久化了

    2. 建议关闭RDB持久化功能,使用AOF持久化,因为AOF每秒刷盘

    3. 利用脚本定期在slave节点做RDB,实现数据备份。(也不建议频繁做)

    4. 设置合理的rewrite阈值,避免频繁的bgrewrite

    5. 配置no-appendfsync-on-rewrite=yes,禁止在rewrite期间做AOF,避免因为AOF引起的阻塞。但是这部分期间,没有做AOF,所以有可能有数据的丢失,这需要看自己是关注可用性还是持久性

    6. Redis实例的物理机需要留足够的内存,应对forkrewrite

    7. 单个Redis实例内存上限不要太大,比如4G或8G,可以加快fork的速度,减少主从同步,数据迁移压力

    8. 不要与CPU密集型应用部署在一起,比如ES

    9. 不要与高硬盘负载应用部署在一起,比如数据库,消息队列

慢查询

  • 只要执行时间超时了,不管是是不是查询语句,都是慢查询
  • slowlog-log-slower-than: 慢查询阈值,单位是微秒,默认是10000,建议配置成1000
  • 慢查询会放入慢查询日志中,日志长度有上限,可以通过配置指定slowlog-max-len,本质是一个队列的长度,默认是129,建议配置成1000,并且需要及时处理
  • slowlog len: 查询慢查询日志长度
  • slowlog get[n]: 读取n条慢查询日志
  • slowlog reset: 清空慢查询列表

安全性问题

  1. Redis一定要设置密码
  2. 禁止线上使用keys , flushall, flushdb, config set命令,可以利用rename-command禁用
  3. bind: 限制网卡,禁止外网网卡访问
  4. 开启防火墙
  5. 不要使用root账户启动Redis
  6. 尽量不是默认的端口

内存配置

  • 内存不足会导致Key频繁被删除,响应时间变长,QPS不稳定,当内存使用率达到90%以上就需要警惕了,并快速定位到内存占用的原因

    内存占用 说明
    数据内存 是Redis最主要的部分,存储Redis键值信息,主要是BigKey问题,内存碎片问题
    进程内存 Redis本身运行肯定占用内存,比如代码,常量池,大约几兆,大多数生产环境中与Redis数据占用的内存相比可以忽略
    缓冲区内存 一般包括客户端缓冲区,AOF缓冲区,复制缓冲区等,客户端缓冲区又包括输入缓冲区和输出缓冲区。这部分内存占用波动较大,不当使用BigKey,可能导致内存溢出
  • 复制缓冲区: 主从复制的repl_backlog_buf,如果太小可能导致频繁的全量复制,影响性能,通过repl-backlog-size设置,默认1M

  • AOF缓冲区: AOF刷盘之前的缓冲区,执行rewrite的缓冲区,无法设置容量上限

  • 客户端缓冲区: 分为输入缓冲区和输出缓冲区,输入缓冲区最大1G不能设置,输出缓冲区可以设置

集群

完整性配置

  • Redis配置中,发现任意一个插槽不可用,则整个集群都停止对外服务。
  • 为了保证高可用性,建议将cluster-require-full-coverage配置为no,默认是yes

带宽问题

  • 集群节点之间会不断的互相ping来确定集群中其他节点的状态,每次ping携带的信息至少包括

    • 插槽信息
    • 集群状态信息
  • 集群中节点越多,集群状态信息数据量也就越大,10个节点的相关信息可能达到1kb,此时每次集群互通需要的带宽会非常高

  • 解决办法:

    1. 避免大集群,集群节点数不要太多,最好少于1000,如果业务庞大,则需要建立多个集群
    2. 避免在单个物理机中运行太多Redis实例
    3. 配置合适的cluster-node-timeout

集群问题

  1. 完整性问题
  2. 带宽问题
  3. 数据倾斜问题
  4. 客户端性能问题
  5. 命令的集群兼容性问题
  6. lua和事务问题(集群模式下没有办法使用lua和事务)
  • 满足需求的前提下,能不使用集群就不用集群。因为单体主从Redis已经能达到万级QPS,已经能满足大部分需求了