Redis是我们开发时最常使用的键值存储工具了,但是与诸多软件工具一样,它也有很多技巧和经验。本文就是将我了解的一些经验和教训分享出来。

使用建议

如果不谨慎使用Redis,可能浪费大量的内存,甚至性能低下。以下时 使用时的几个具体建议:

命名规范

通常一个Redis实例有16个数据库,通常你的业务并不是独立的数据库。为了与其它业务避免冲突,通常会用前缀后缀做一些区分,比如"业务名称:原始key"作为真正存储的key。但是要注意的是,Key本身是字符串,Redis的底层数据结构是SDS。从Redis3.2开始,key长度增长时,SDS占用内存空间也会增加,所以Key的长度我们要适当控制。SDS 结构中的字符串长度和元数据大小的对应关系如下表所示:

字符串大小(字节) SDS结构元数据大小(字节)
1~(2^5-1) 1
(2^5)~(2^8-1) 1
(2^8)~(2^16-1) 1
(2^16)~(2^32-1) 1
(2^32)~(2^64-1) 1

避免使用 bigkey

Redis效率如此高的一个原因是,它是单线程服务的模型。如果有bigkey,读写的时候就会阻塞线程,降低redis的处理效率。bigkey有两种情况:

  • 键值对的值本身就很大,超过了1MB。这通常需要业务把存储的value降低到10k以下,可以采取数据压缩的办法来减少内存占用
  • 键值对的值是集合类型,而集合的元素非常多。需要业务将value控制在1万以下比较适合,必要的话就拆分集合

使用高效的序列化和压缩方法

Java有多种序列化方式,比如protostuff和kryo,比Java内置的序列化方法更高效。有时候业务还会使用XML或者JSON存储。有时候为了避免XML和JSON的体积过大,可以使用snappy或者gzip压缩后再存入Redis,可以减少60%的内存占用

使用整数对象共享池

Redis内部维护了0-9999共一万个整数对象,并且把这一万个整数作为一个共享池使用。但是也有两种情况无法使用共享池:

  • 如果Redis设置了maxmemory,而且开启了LRU策略(allkeys-LRU或者volatile-LRU),就无法使用共享池,因为无法判断LRU了
  • 如果集合类型的数据采用ziplist编码,而集合元素是整数u,也不能使用共享池,因为ziplist使用了紧凑的内存结构,判断整数对象的共享情况效率低下

利用Hash优化缓存

如果是同一个实体的多个属性做缓存,用同一个key的hash来存储,可以减少额外的元数据占用

尽可能减少存储的数据量

不论是string,List,Set还是Zset,底层实现都是多样的。Redis会根据数据结构中的数据量做动态调整。比如字符串,如果是少于20的整数,会直接存储在指针里;小于等于44的字符串会存储在一段连续的44长度的内存。如果你存储在这些结构里的数据量少于阈值,就可以采用更简单高效的数据结构,从而带来一定的收益。

只用来保存热数据

Redis使用的是最昂贵的内存进行存储,RDB和AOF日志持久化保存数据都只是提供数据可靠性保障的,并不能扩充容量。因此业务最好只存储热点数据。

不同的业务数据分实例存储

即便我们采用前缀的方式区分了存储的key,但是如果两个大量访问的业务都存储在同一个实例上,还是会对Redis的性能产生影响。因此最好不同的业务将数据存储在不同的Redis实例上。

数据保存时,设置合理的过期时间

如果过期时间不合理,要么Redis在不停的换入过期,要么内存很快撑爆服务器,对业务产生影响。

控制每个Redis实例的容量

Redis单实例的内存不要太大,通常建议时2-6GB。这样无论是RDB快照,还是主从集群的数据同步,速度都很快,不会阻塞正常的请求。

线上禁用命令

Redis是单线程处理请求,因此有些命令在线上使用有可能会长时间阻塞主线程。

  • KEYS,按照键值对的key进行内存的匹配,需要对Redis的全局哈希表进行扫描,严重阻塞Redis主线程,可以用SCAN命令代替,分批返回符合条件的键值对。使用SCAN后要记得主动删除游标
  • FLUSHALL,删除实例上所有的数据,如果数据量很大,严重阻塞Redis主线程。可以加上ASYNC选项,异步删除数据
  • FLUSHDB,删除当前数据库里的数据,同样可能阻塞Redis主线程。可以加上ASYNC选项,异步删除数据

慎用MONITOR命令

monitor命令可以监控Redis的运行状态,但是它会把所有的操作返回都输出到输出缓存区域。如果操作很多,输出缓存会很快溢出,这对Redis的性能造成影响。

慎用全量操作命令

对于集合类型来说,如果要获取全部数据,一般不建议使用全量操纵指令,比如HGETALL,SMEMBERS。它们会对数据结构做全量扫描,集合类型数据较多的话,会阻塞Redis主线程。建议如下:

  • 可以使用SSCAN,HSCAN分批返回集合的数据,减少阻塞
  • 可以化整为零,将一个大的集合拆成多个小集合
  • 如果存储的是多个业务的多个属性,而每次查询的时候也要返回这些属性,那么可以用String类型,将这些属性序列化后存储,使用时直接返回String即可。

常见问题

雪崩

当大量缓存数据在同一时刻过期(即缓存数据失效)或者Redis故障宕机时,此时如果有大量的请求访问Redis缓存数据则会导致大量请求会直接访问数据库(即直接压入数据库),导致数据库压力剧增,严重的情况会导致数据库直接宕机,致使整个系统崩溃,这就是缓存雪崩。 出现缓存雪崩主要是因为:

  • Redis中大量缓存数据同一时间过期
  • Redis故障宕机

解决方案:

  • 过期时间设置随机值。这个方法最常用,数据的过期时间分散开来,就不会同时过期了
  • 加互斥锁,然而在高并发的情况下,互斥锁会降低系统的吞吐能力。
  • 双key策略,主key设置过期时间,副key不设置过期时间,两个key保存的值value是相同的,相当于副key是缓存数据的副本,当主key过期时可以直接返回副key的value(即缓存数据),而在主key更新时同时要更新主key和副key的缓存数据value。
  • 针对Redis宕机,可以暂停对Redis的访问,直接返回错误;或者搭建高可用Redis集群,主节点宕机后可以自动切换节点。

穿透

用户访问的数据压根不存在Redis缓存中,也不存在数据库中,导致用户发送请求访问缓存时,缓存失效,便直接访问数据库也没有拿到数据,这样的话当有大量这样的请求打入时,数据库的压力会剧增,同样可能会导致数据库崩溃从而导致系统崩溃,这就是缓存穿透。

一般导致缓存穿透的情况有两种:

  • 业务误操作(数据意外丢失)
  • 黑客恶意攻击(故意访问一些不存在的数据)

解决方案:

  • 设置缓存空值或者默认值,这种方案比较常用,查询不到数据的时候,可以在缓存里先放一个空值或者默认值,这样后续请求就会直接通过缓存返回了
  • 非法请求的限制,入参前就校验是否合理,对非法请求进行拦截
  • 设置可以访问的白名单。Redis的bitmaps类型可以定义一个可以访问的名单,如果访问的id不在bitmaps里,则不进行访问。
  • 使用布隆过滤器,当数据写入数据库的时候,则更新布隆过滤器,当访问缓存数据不存在的时候,可以通过布隆过滤器尝试分析。布隆过滤器有一定的误判,查到数据库存在的数据有可能有一部分并不是真正存在的,但是检查不到的一定不在数据库。

击穿

大量用户请求同一个数据(即热点数据被频繁访问),刚好缓存中的热点数据过期,此时就会导致大量直接访问数据库,这样数据库很容易被高并发搞崩溃,击穿顾名思义针对一点不断打击,这就是缓存击穿。

解决方案:

  • 加互斥锁,但是显然这种方式会导致吞吐能力下降
  • 热点数据不设置过期时间,或者设计热点数据更新缓存,延长过期时间

缓存污染

缓存污染指的是缓存中一些只会被访问一次或者几次的的数据,被访问完后,再也不会被访问到,但这部分数据依然留存在缓存中,消耗缓存空间。缓存污染会随着数据的持续增加而逐渐显露,随着服务的不断运行,缓存中会存在大量的永远不会再次被访问的数据。缓存空间是有限的,如果缓存空间满了,再往缓存里写数据时就会有额外开销,影响Redis性能。

解决方案:

可以通过调整最大缓存值来避免内存太小影响性能,又要避免设置太大,影响同步。可以设置缓存容量为总数据量的15%-30%。

一致性问题

Redis作为缓存时读取数据时遇到的缓存失效后,在并发更新操作下,可以使用双写模式(即同时更新数据库和缓存),那么是先更新缓存,再更新数据库 还是 先更新数据库,再更新缓存呢?

  • 先更新缓存,再更新数据库,线程1和线程2并发的进行更新数据操作,由于线程1在执行过程中存在卡顿情况,导致线程2执行完操作,才到线程1执行后面的操作。通过上图可以看到执行到最后的过程得到的结果是 数据库中的数据是数据1,缓存中的数据是数据2。 出现了缓存和数据库不一致的问题。
  • 先更新数据库,再更新缓存,线程1和线程2并发的进行更新数据操作,同样由于线程1在执行过程中存在卡顿情况,导致线程2执行完操作,才到线程1执行后面的操作。过上图可以看到执行到最后的过程得到的结果是 数据库中的数据是数据2,缓存中的数据是数据1。 出现了缓存和数据库不一致的问题。
  • 考虑删除缓存和更新数据库有失败的情况下, 情况会更复杂。

解决方法:

  • 如果不要求数据具备实时性,能有一定的延迟的话,只需要在缓存中加上过期时间,这样数据最终总会一致。
  • 如果对数据的一致性要求很高的话,则在更新缓存前加上分布式锁,保证同一时刻只允许一个线程进行操作,这样会避免了并发导致的问题,但是吞吐能力大大降低。
  • 写入数据库如果失败的话,整个流程是肯定失败的,所有操作都应该回退。所以先写缓存的情况会更复杂。
  • 写入数据库成功,但是删除/更新缓存失败,则应设置一定次数的重试,尝试更新缓存。如果重试次数超出,只能依赖缓存超期
  • 先更新数据库再删除缓存的情况下,还可以利用binlog同步的机制,将数据更新到Redis里,可靠性更高

HotKey问题

在较短的时间内,海量请求访问一个Key,这样的Key就被称为HotKey。海量请求在较短的时间内,访问一个Key,势必会导致被访问的Redis服务器压力剧增,可能会将Redis服务器击垮,从而影响线上业务;HotKey过期的一瞬间,海量请求在较短的时间内,访问这个Key,因为Key过期了,这些请求会走到数据库,可能会将数据库击垮,从而影响线上业务。

治理Hotkey,需要解决从发现到通知Hotkey的产生,再到治理的过程。

如何发现Hotkey又可以拆解为两个子问题:如何知道每个Key的使用情况,以及如何统计。想要知道每个key的使用情况,可以在Redis客户端做埋点,也可以统一做一个Redis的代理,在代理层做埋点。前者管理不太方便,后者引入了新的故障点,需要综合取舍。统计的方式也有两种,一种是实时上报,业务系统的压力较小,但是上报数据很多;或者准实时上报,几类一定量或时间再上报,对业务自己有一定的内存压力。

发现Hotkey以后,由发现Hotkey的角色,通过MQ/RPC等方案触及客户端,告诉客户端Hotkey是什么,从而使得客户端可以处理Hotkey。

当客户端收到Hotkey以后,可以采用如下方式:

  • 本地短时缓存Hotkey的数据,避免压力全部集中在Redis
  • 将此Hotkey多分片存储到多个Redis实例,分散Hotkey的压力

此外,如果Redis采用主从+哨兵集群,则如果master节点挂了,就会自动切换一个slave为master,继续提供服务。这样也可以提高Redis的可用性。

事务里分布式锁失效

使用Redis做分布式锁还会遇到一种情况,就是在Transactional注解里使用分布式锁。可能代码会如下:

1
2
3
4
5
6
7
8
9
@Transactional  
public void updateDB(int lockKey) {  
  boolean lockFlag = redisLock.lock(lockKey);  
  if (!lockFlag) {  
    throw new RuntimeException(请稍后再试);  
  }  
   doBusiness //业务逻辑处理  
   redisLock.unlock(lockKey);  
}

它的问题是事务是函数返回后才执行的,但是此前分布式锁已经释放了,另一个线程完全可以做出不同的业务逻辑,导致最后事务无法提交,分布式锁等于失效了。有时候这个问题隐藏得会很深,必须得留意。

为什么是Redisson而不是自己写分布式锁

说到Redis用作存储分布式锁,有时候我们会看到有人这么写:

1
2
3
4
ifjedis.setnx(lock_key,lock_value) == 1{ //加锁
	jedis.expire(lock_keytimeout; //设置过期时间 
	doBusiness //业务逻辑处理 
}

这里的问题是,setnx和expire不是原子的。如果这个时候网断了,服务器挂了,都可能造成一个永久的锁。

还有人可能会这么写

1
2
3
4
5
if(jedis.set(lockKey, requestId, "NX", "PX", expireTime)==1){ //加锁  
   doBusiness //业务逻辑处理  
   return true; //加锁成功,处理完业务逻辑返回  
}  
return false; //加锁失败

return前没有释放锁,那么下次加锁只能等待expireTime超期了。

还有一种写法,使用getSet写的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
long expireTime = System.currentTimeMillis() + timeout; //系统时间+设置的超时时间  
String expireTimeStr = String.valueOf(expireTime); //转化为String字符串  
//锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间(不了解redis的getSet命令的小伙伴,可以去官网看下哈)  
...//加锁失败,因为锁已存在
// 如果锁已经存在,获取锁的过期时间  
String oldExpireTimreStr = jedis.get(lock_key);  
...//存在一个旧的锁,但是锁的超时时间已经过了
String oldValueStr = jedis.getSet(lock_key, expireTimeStr);  
if (oldValueStr != null && oldValueStr.equals(oldExpireTimreStr)) {  
	//考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁  
	return true;  
}  

这段代码的问题是,如果getSet有多个终端同时请求,那么有可能最后加锁成功的线程的超时时间,跟jedis里的value并不一致。

还有一些问题跟释放锁有关系。比如:

1
2
3
 if (requestId.equals(jedis.get(lockKey))) { //判断一下是不是自己的requestId  
      unlock(lockKey);//释放锁  
    }

有可能判断的时候锁是自己的,但是立刻过期了,然后被其它线程抢到了锁。那么最后unlock的时候,其实是解除了其它的锁。

还有锁超时释放了,但是业务没跑完,此时另一个线程会与当前业务形成竞争。这个最好是开启一个守护线程,业务没跑完的时候,就定期对锁做一些延长操作,可以避免提前释放。

就是由于有这么多问题,所以最好的办法,就是使用Redission。下边就是Redission的加锁方案:

此外,它还有WatchDog,每10秒监控一次锁的状态,如果业务没有释放,则自动续期30秒,避免业务没完成时锁被释放导致业务问题。

释放锁的时候,它会注意锁的可重入性,如果全部的锁都释放了,就会删除锁,然后广播锁的删除事件,再取消WatchDog对锁的自动续期。通常来说分布式锁都应当采用Redisson来维护。