
本文出现代码均为阿东的简单实现,仅供参考,不可直接用于生产
在业务中总有些数据的访问非常频繁,比如热门网站首页、热搜新闻等,这些就叫做热 key(hot key);单机的 Redis 官网号称可以扛住 10 W 的读并发,可当并发高于 10 W,单机的 Redis 就没有办法支持。我们 Redis 在线上一般采用数据分片思想的分片集群模式,数据存取通过一致性哈希算法路由到对应 Redis 节点上来提升整体并发度。
热 key 的解决方案一般是使用多级缓存。
多级缓存
对于热点数据,网络开销已经不能忽虑,我们可以将热点数据在本地存一份,当有读请求时,直接从本地获取省去网络开销,并发度也会大幅提升;但由于本地内存空间是有限的,所以只能存部分最热的数据,我们可以使用支持 LRU/LFU 内存淘汰策略的 Guava 和 Caffeine 缓存框架。
本地缓存的一致性问题
缓存的不一致分为「不同级之间」和「同级之间」,我们这里只讨论同级缓存之间的不一致(不同级不一致的问题可以看阿东之前的文章《浅谈缓存一致性》),同级缓存之间一致性的重点就在于各个节点需要进行通信,比较好的方式是利用消息队列的「生产者/消费者」模型,当有缓存数据变更时发出一条删除消息,所有节点消费到删除消息后将对应的本地缓存失效掉,这样就能保证各个节点缓存的一致性。
热 key 检测
本地缓存是为了热 key 准备的,很多情况下热 key 是不固定的(比如一些突然爆火的视频文章和促销商品),所以上面的方案在一些场景下不是很灵活,如果有条件可以在 Redis 入口处做个代理服务,专门检测热 key(统计 key 的访问频率等),当达到热 key 标准就通知系统把该 key 加到本地缓存中。
写热点
二. big key
内存分配耗时:由于 Redis 处理命令是单线程的,当应用写入一个big key 时,更多的时间将消耗在内存分配上,操作的耗时就会增加,删除 big key 也一样;
网络传输耗时:在 Redis 6.0 之前,从处理网络请求到执行命令都是单线程完成(Redis 6.0 采用多线程处理网络请求),而读取 big key 时,在网络数据传输上也会花费更多时间,后面请求就会排队等待,进而影响整体吞吐量。
public static byte[] compress(Serializable value) {try (ByteArrayOutputStream buf = new ByteArrayOutputStream(512);ObjectOutputStream output = new ObjectOutputStream(buf);// 压缩后的数据ByteArrayOutputStream gzipBuf = new ByteArrayOutputStream();GZIPOutputStream gzip = new GZIPOutputStream(gzipBuf)) {output.writeObject(value);// 序列化之后的数据byte[] ba = buf.toByteArray();gzip.write(ba);return gzipBuf.toByteArray();} catch (Exception e) {log.error("compress error", e);throw new UnsupportedOperationException(e);}}
public static void main(String[] args) {StringBuilder sb = new StringBuilder();for(int i = 0; i < 10000; i++) {sb.append(UUID.randomUUID().toString());}String value = sb.toString();byte[] base = value.getBytes();System.out.println("压缩前 " + base.length);byte[] compress = compress(value);System.out.println("压缩后 " + compress.length);}

/*** 将集合按照 size 分片*/public static <T> List<List<T>> partition(List<T> list, int size) {List<List<T>> res;// 如果待分片集合元素数量大于size则进行分片if(list.size() > size) {int len = list.size() size + 1;res = new ArrayList<>(len);for(int i = 0; i < list.size(); i += size) {int limit = i + size;res.add(list.subList(i, limit > list.size() ?list.size() : limit));}} else {res = new ArrayList<>(1);res.add(list);}return res;}
// setList 方法为将集合设置进缓存public <T> void shardingSet(String key, List<T> list, long seconds, Jedis jedis) {// 将集合按照 size 分片List<List<T>> partitions = partition(list, 1000);// 收集分片 keyList<String> subKeys = new ArrayList<>(partitions.size());for(int i = 0; i < partitions.size(); i++) {String subKey = key + i;subKeys.add(subKey);// 设置分片 List 缓存setList(subKey, partitions.get(i), seconds, jedis);}// 设置分片 key 集合缓存setList(key, subKeys, seconds, jedis);}
// getList 方法为从缓存获取集合public <T> List<T> shardingGet(String key, Jedis jedis) {// 获取分片 key 集合List<String> subKeys = getList(key, jedis);List<T> res = new ArrayList<>();if (CollectionUtils.isEmpty(subKeys)) {return res;}// 组装各个分片缓存集合for(String subKey: subKeys) {res.addAll(getList(subKey, jedis));}return res;}
三. 缓存雪崩
缓存雪崩是指大量的请求无法在Redis(缓存层)中进行处理,接着被应用送到了数据库层,导致数据库的压力激增。
一般发生缓存雪崩有两种原因:缓存中大批量数据同时过期 和 缓存服务宕机。
1. 缓存中大批量数据同时过期。
有些业务场景,会在同一时刻塞入多个缓存,在某一时刻,这些缓存同时失效,此时,当应用对这些数据进行访问时就会发生缓存缺失,从而去访问数据库层,对数据库造成压力,甚至可能造成数据库宕机影响整个系统。
解决方案
随机过期时间:我们可以在设置过期时间时在原基础上随机加上一小段时间(比如随机 0 ~ 6 分钟),可以减小缓存数据同时过期的概率。
代码简单实现如下:
private static final long RANDOM_EXPIRE_SECONDS = 60 * 6;public boolean setex(String key, String value, long seconds, Jedis jedis) {try {jedis.setex(key, seconds + ThreadLocalRandom.current().nextLong(RANDOM_EXPIRE_SECONDS), value);return true;} catch (Throwable t) {log.error("setex error, key={}", key, t);return false;}}
2. Redis 宕机
当 Redis 发生宕机后,大量请求会直接打到数据库,对数据库造成较大压力,对于这个问题有两种方案,分为「事前」和「事后」:
第一种方案(事前):事前预防,做好 Redis 的高可用(主从节点)
第二种方案(事后):在业务系统中实现服务熔断、降级、限流
当我们监测到 Redis 服务宕机后(长时间 ping 不通或收到公司监测平台通知后),可以启动服务熔断机制,一段时间内直接访问数据库而不再访问 Redis ,同时需要开启对数据库访问的排队限流或只保留核心业务访问,剩余业务提示“业务繁忙”等字样。
四. 缓存击穿
缓存击穿是指:某个热点数据无法在缓存中被处理,大量请求都打到数据库,导致数据库压力激增。缓存击穿一般发生在热点数据过期。
缓存击穿的解决方案主要有两种:
1. 有种简单粗暴的方案,就是把热点的数据设置永不过期;
2. 设置永不过期的方式虽然简单,但是不够灵活,很多情况下热点 key 是不固定的(比如一些突然爆火的视频文章和促销商品),当发生了缓存击穿(热点数据过期),比较好的方式就是使用互斥锁对数据库的访问进行限流,由于只是限流访问,使用本地锁即可,比较适合的就是高性能的双检锁模式(不了解双检锁的可以去看阿东之前的文章《单例模式那些事儿》)。
双检锁模式:
public String doubleCheckLock(String key) {// 一检String value = getCache(key);if(!Strings.isNullOrEmpty(value)) {return value;}synchronized (key.intern()) {// 二检value = getCache(key);if(!Strings.isNullOrEmpty(value)) {return value;}// 从数据库中查并写回缓存value = getDB(key);setCache(key, value);}return value;}
五. 缓存穿透
缓存穿透是指要访问的数据既不在缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时也没有值;这样的情况下,缓存就成了摆设,如果访问这些缺失数据的请求持续高并发,就会给缓存和数据库同时带来压力。
造成缓存穿透有两种原因:
业务层操作:数据被删。
恶意攻击:专门攻击数据库没有的数据。
解决方案同样分为「事前」和「事后」。
事前方案
前端过滤:在系统请求入口处对请求进行检测,将恶意请求过滤(请求参数不合理,非法值)。
事后方案
设置空值:当发生缓存穿透时,我可以设置一个空值(或是业务统一的缺省值)到缓存中。当后续还有请求打过来,就可以在缓存层直接过滤返回,避免大量请求打到数据库。使用该方案需要注意一点,当新增或删除该数据时需要删除缓存,否则可能会造成缓存与数据库不一致。
伪代码如下:
// 从缓存中查String value = getCache(key);if(value != null) {return value;}// 从数据库中查value = getByDB(key);if(value == null) {// 数据库中也为空就将空值写回缓存setCache(key, "NULL", 120);} else {setCache(key, value, 1200);}return value;
布隆过滤器:布隆过滤器由一个初始值都为 0 的 bit 数组和 N 个哈希函数组成,主要分为「标记」和「判断存在」两种操作。
当我们想将标记值为存在时,布隆过滤器的工作流程如下:
1. 使用 N 个哈希函数对待标记数据计算出 N 个哈希值;
2. 将这 N 个哈希值对 bit 数组的长度进行取模,得到每个哈希值在数组中的位置;
3. 把数组中对应位置设置为 1。
当我们想判断某个值是否存在,布隆过滤器的工作流程如下:
1. 使用 N 个哈希函数对待标记数据计算出 N 个哈希值;
2. 将这 N 个哈希值对 bit 数组的长度进行取模,得到每个哈希值在数组中的位置;
3. 判断数组中每个位置是否都为 1:如果是,则存在;如果有一个位置的值不为 1,则不存在。
bit 数组长度越大、哈希函数越多准确度越高。
我们可以在新增或编辑时将数据加进布隆过滤器(历史数据可以在上线前通过脚本加入),后续在查询缓存和数据库之前先到布隆过滤器中快速判断数据是否存在,不存在的数据快速过滤掉。
!
《Redis 核心技术与实战》作者:蒋德钧
https://mp.weixin.qq.com/s/oDV-2IkX16EffLcStT0bSg
https://redis.io/docs




