缓存设计

缓存穿透

缓存穿透是指查询⼀个根本不存在的数据,缓存层和存储层都不会命中,通常出于容错的考虑,如果从存储层查不到数据则不写⼊缓存层。
缓存穿透将导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。造成缓存穿透的基本原因有两个:
1. ⾃身业务代码或者数据出现问题;
2.⼀些恶意攻击、 爬⾍等造成⼤量空命中。

解决方案

空缓存

当查询数据库为空时,可以把这个 key 的内容设置为空缓存,并设置过期时间,避免占⽤⼤量的内 存,下次查询相同的 key 时会先从缓存中查,缓存中则会存在空缓存,直接返回。

  1. String get(String key) {
  2. // 从缓存中获取数据
  3. String cacheValue = cache.get(key);
  4. // 缓存为空
  5. if (StringUtils.isBlank(cacheValue)) {
  6. // 从存储中获取
  7. String storageValue = storage.get(key);
  8. cache.set(key, storageValue);
  9. // 如果存储数据为空, 需要设置一个过期时间(300秒)
  10. if (storageValue == null) {
  11. cache.expire(key, 60 * 5);
  12. }
  13. return storageValue;
  14. } else {
  15. // 缓存非空
  16. return cacheValue;
  17. }
  18. }

布隆过滤器

对于恶意攻击,向服务器请求⼤量不存在的数据造成的缓存穿透,还可以⽤布隆过滤器先做⼀次过 滤,对于不存在的数据布隆过滤器⼀般都能够过滤掉,不让请求再往后端发送。当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。
image.png
布隆过滤器就是⼀个⼤型的位数组和⼏个不⼀样的⽆偏 hash 函数。所谓⽆偏就是能够把元素的 hash 值算得⽐较均匀。
向布隆过滤器中添加 key 时,会使⽤多个 hash 函数对 key 进⾏ hash 算得⼀个整数索引值然后对 位数组⻓度进⾏取模运算得到⼀个位置,每个 hash 函数都会算得⼀个不同的位置。再把位数组的这⼏个位置都置为 1 就完成了 add 操作。
向布隆过滤器询问 key 是否存在时,跟 add ⼀样,也会把 hash 的⼏个位置都算出来,看看位数组中这⼏个位置是否都为 1,只要有⼀个位为 0,那么说明布隆过滤器中这个key 不存在。如果都是 1,这并不能说明这个 key 就⼀定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组⽐较稀疏,这个概率就会很⼤,如果这个位数组⽐较拥挤,这个概率就会降低。
这种方法适用于数据命中不高、 数据相对固定、 实时性低(通常是数据集较大) 的应用场景, 代码维护较为复杂, 但是缓存空间占用很少。
可以⽤redisson实现布隆过滤器,引⼊依赖:

  1. <dependency>
  2. <groupId>org.redisson</groupId>
  3. <artifactId>redisson</artifactId>
  4. <version>3.6.5</version>
  5. </dependency>

注:布隆过滤器不能删除数据,如果要删除得重新初始化数据。

  1. import org.redisson.Redisson;
  2. import org.redisson.api.RBloomFilter;
  3. import org.redisson.api.RedissonClient;
  4. import org.redisson.config.Config;
  5. public class RedissonBloomFilter {
  6. public static void main(String[] args) {
  7. Config config = new Config();
  8. config.useSingleServer().setAddress("redis://localhost:6379");
  9. //构造Redisson
  10. RedissonClient redisson = Redisson.create(config);
  11. RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
  12. //初始化布隆过滤器:预计元素为100000000L,误差率为3%,根据这两个参数会计算出底层的bit数组⼤⼩
  13. bloomFilter.tryInit(100000L,0.03);
  14. //将zhuge插⼊到布隆过滤器中
  15. bloomFilter.add("zhuge");
  16. bloomFilter.add("tuling");
  17. //判断下⾯号码是否在布隆过滤器中
  18. System.out.println(bloomFilter.contains("guojia"));//false
  19. System.out.println(bloomFilter.contains("baiqi"));//false
  20. System.out.println(bloomFilter.contains("zhuge"));//true
  21. }
  22. }

使⽤布隆过滤器需要把所有数据提前放⼊布隆过滤器,并且在增加数据时也要往布隆过滤器⾥放。

  1. //初始化布隆过滤器
  2. RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
  3. //初始化布隆过滤器:预计元素为100000000L,误差率为3%
  4. bloomFilter.tryInit(100000000L,0.03);
  5. //把所有数据存⼊布隆过滤器
  6. void init(){
  7. for (String key: keys) {
  8. bloomFilter.put(key);
  9. }
  10. }
  11. String get(String key) {
  12. // 从布隆过滤器这⼀级缓存判断下key是否存在
  13. Boolean exist = bloomFilter.contains(key);
  14. if(!exist){
  15. return "";
  16. }
  17. // 从缓存中获取数据
  18. String cacheValue = cache.get(key);
  19. // 缓存为空
  20. if (StringUtils.isBlank(cacheValue)) {
  21. // 从存储中获取
  22. String storageValue = storage.get(key);
  23. cache.set(key, storageValue);
  24. // 如果存储数据为空, 需要设置⼀个过期时间(300秒)
  25. if (storageValue == null) {
  26. cache.expire(key, 60 * 5);
  27. }
  28. return storageValue;
  29. } else {
  30. // 缓存⾮空
  31. return cacheValue;
  32. }
  33. }

缓存击穿(缓存失效)

由于⼤批量缓存在同⼀时间失效可能导致⼤量请求同时穿透缓存直达数据库,可能会造成数据库瞬 间压⼒过⼤甚⾄挂掉,对于这种情况我们在批量增加缓存时最好将这⼀批数据的缓存过期时间设置为⼀个时间段内的不同时间。
通常在设置缓存有效期时,会给不同的 key 设置⼀个随机的数 + ⾃然数的有效期,避免⼤量缓存同时失效。

缓存雪崩

缓存雪崩指的是缓存层⽀撑不住或宕掉后,流量会打向后端存储层。由于缓存层承载着⼤量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供服务(⽐如超⼤并发过来,缓存层⽀撑不住,或者由于缓存设计不好,类似⼤量请求访问 bigkey,导致缓存能⽀撑的并发急剧下降),于是⼤量请求都会打到存储层,存储层的调⽤量会暴增, 造成存储层也会级联宕机的情况。
预防和解决缓存雪崩问题, 可以从以下三个⽅⾯进⾏着⼿。

解决方案

缓存高可用

保证缓存层服务⾼可⽤性,⽐如使⽤ Redis Sentinel 或 Redis Cluster。

熔断降级

依赖隔离组件为后端限流熔断并降级。⽐如使⽤ Sentinel 或 Hystrix 限流降级组件。
⽐如服务降级,我们可以针对不同的数据采取不同的处理⽅式。当业务应⽤访问的是⾮核⼼数据 (例如电商商品属性,⽤户信息等)时,暂时停⽌从缓存中查询这些数据,⽽是直接返回预定义的默认降级信息、空值或是错误提示信息;当业务应⽤访问的是核⼼数据(例如电商商品库存)时,仍然允许 查询缓存,如果缓存缺失,也可以继续通过数据库读取。

预案设定

在项⽬上线前,演练缓存层宕掉后,应⽤以及后端的负载情况以及可能出现的问题,在此基础上做⼀些预案设定。

热点Key

如果⼀个不存在缓存中的 key,突然成为热点 key(例如⼀个热⻔的娱乐新闻),并发量⾮常⼤。 或者当缓存中的 key 失效时,⼤量的线程同时重建缓存,如果重建缓存是⼀个⽐较复杂的过程例如复杂的SQL、多次IO、多个依赖等,那么会造成后端负载加⼤,甚⾄可能会让应⽤崩溃。
要解决这个问题主要就是要避免⼤量线程同时重建缓存,可以利⽤互斥锁(分布式锁)来解决,此 ⽅法只允许⼀个线程重建缓存,其他线程等待重建缓存的线程执⾏完,重新从缓存获取数据即可。

缓存&数据库双写不⼀致

在⾼并发的场景下可能会出现缓存和数据库内容不⼀致的情况,例如现在有⼀个更新数据库的⽅法,在更新完数据库后会更新缓存中的值,那么当线程1修改数据库内容A = 10之后,还没修改缓存,此时线程2修改了数据库A = 5,并更新了缓存内容,线程1再去修改缓存时会把缓存中的A修改为10,⽽数据库中的A却是5。
image.png
或者在⼀些情况中,当更新完数据库后会删除掉缓存的内容,等查询时再判断是否存在缓存,不存 在则写⼊,这种修改⽅式在⾼并发的场景下也会出现问题,例如下图中,缓存内容被线程1删除,当线程3查询时,缓存为空,那么去查数据库 stock = 10,在线程3更新缓存之前,线程2修改了数据 stock = 6,此时线程3再修改缓存时,数据库中是 stock = 6,缓存是 stock = 10。
image.png解决双写不⼀致问题有以下⼏种⽅式:
1)对于并发⼏率很⼩的数据(如个⼈维度的订单数据、⽤户数据等),这种⼏乎不⽤考虑这个问 题,很少会发⽣缓存不⼀致,可以给缓存数据加上过期时间,每隔⼀段时间触发读的主动更新即可。
2)就算并发很⾼,如果业务上能容忍短时间的缓存数据不⼀致(如商品名称,商品分类菜单 等),缓存加上过期时间依然可以解决⼤部分业务对于缓存的要求。
3)如果不能容忍缓存数据不⼀致,可以通过加分布式读写锁保证并发读写或写写的时候按顺序排好队,读读的时候相当于⽆锁,可以设置⼀个读 key 和写 key 来分别加分布式锁,或者使⽤ redisson 提供的读写锁,基于 lua 脚本实现。

RReadWriteLock rwlock = redisson.getReadWriteLock(lockKey);
RLock rLock = rwlock.readLock();
rLock.lock();
RLock wLock = rwlock.writeLock();
wLock.lock();

在 lua 脚本中,增加⼀个 mode ⽤来标记时读锁还是写锁。
image.png

return this.commandExecutor.evalWriteAsync(this.getName(),
LongCodec.INSTANCE, command,
"local mode = redis.call('hget', KEYS[1], 'mode');
if (mode == false) then redis.call('hset', KEYS[1], 'mode', 'read');
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('set', KEYS[2] .. ':1', 1);
redis.call('pexpire', KEYS[2] .. ':1', ARGV[1]);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
if (mode == 'read') or (mode == 'write' and redis.call('hexists',
KEYS[1], ARGV[3]) == 1)
then local ind = redis.call('hincrby', KEYS[1], ARGV[2], 1);
local key = KEYS[2] .. ':' .. ind;redis.call('set', key, 1);
redis.call('pexpire', key, ARGV[1]);
redis.call('pexpire', KEYS[1], ARGV[1]); return nil;
end;return redis.call('pttl', KEYS[1]);",
Arrays.asList(this.getName(),
this.getReadWriteTimeoutNamePrefix(threadId)), new Object[]
{this.internalLockLeaseTime, this.getLockName(threadId),
this.getWriteLockName(threadId)});

4)也可以⽤阿⾥开源的 canal 通过监听数据库的 binlog ⽇志及时的去修改缓存,但是引⼊了新的中间件,增加了系统的复杂度。
image.png
以上我们针对的都是读多写少的情况加⼊缓存提⾼性能,如果写多读多的情况⼜不能容忍缓存数据 不⼀致,那就没必要加缓存了,可以直接操作数据库。当然,如果数据库抗不住压⼒,还可以把缓存作为数据读写的主存 储,异步将数据同步到数据库,数据库只是作为数据的备份。放⼊缓存的数据应该是对实时性、⼀致性要求不是很⾼的数据。切记不要为了⽤缓存,同时⼜要保证绝对的⼀致性做⼤量的过度设计和控制,增加系统复杂性!

开发规范与性能优化

key 的设计

可读性和可管理性:以业务名(或数据库名)为前缀(防⽌key冲突),⽤冒号分隔,⽐如业务名: 表名:id。
简洁性:保证语义的前提下,控制 key 的⻓度,当 key 较多时,内存占⽤也不容忽视。
不包含特殊字符:空格、换⾏、单双引号以及其他转义字符。

value 的设计

bigkey 的定义

为了防⽌⽹卡流量、慢查询,在 Redis 中,⼀个字符串最⼤ 512MB,⼀个⼆级数据结构(例如 hash、list、set、zset)可以存储⼤约40亿个(2^32-1)个元素,但实际中如果下⾯两种情况,会认为它是bigkey。

  • 字符串类型

它的 big 体现在单个 value 值很⼤,⼀般认为超过 10KB 就是 bigkey。

  • ⾮字符串类型

    哈希、列表、集合、有序集合,它们的 big 体现在元素个数太多。 ⼀般来说,string 类型控制在 10KB 以内,hash、list、set、zset 元素个数不要超过 5000。 反例:⼀个包含200万个元素的list。
    ⾮字符串的 bigkey,不要使⽤ del 删除,使⽤ hscan、sscan、zscan ⽅式渐进式删除,同时要注意防⽌bigkey 过期时间⾃动删除问题(例如⼀个200万的 zset 设置1⼩时过期,会触发del操作,造成阻塞)。

    bigkey 的问题

    导致redis阻塞,⽹络拥塞,bigkey 也就意味着每次获取要产⽣的⽹络流量较⼤,假设⼀个 bigkey 为 1MB,客户端每秒访问量为1000,那么每秒产⽣1000MB的流量,对于普通的千兆⽹卡(按照字节算是128MB/s)的服务器来说简直是灭顶之灾,⽽且⼀般服务器会采⽤单机多实例的⽅式来部署,也就是说⼀个 bigkey 可能会对其他实例也造成影响,其后果不堪设想。
    有个bigkey,它安分守⼰(只执⾏简单的命令,例如hget、lpop、zscore等),但它设置了过期时间,当它过期后,会被删除,如果没有使⽤Redis 4.0的过期异步删除(lazyfree-lazy expire yes),就会存在阻塞Redis的可能性。

    bigkey 的产⽣

    bigkey 的产⽣都是由于程序设计不当,或者对于数据规模预料不清楚造成的,例如: 粉丝列表,如果某些明星或者⼤v不精⼼设计下,必是bigkey。 按天存储某项功能或者⽹站的⽤户集合,除⾮没⼏个⼈⽤,否则必是bigkey。
    将数据从数据库 load 出来序列化放到 Redis ⾥,这个⽅式⾮常常⽤,但有两个地⽅需要注意,第 ⼀,是不是有必要把所有字段都缓存;第⼆,有没有相关关联的数据,有的同学为了图⽅便把相关数据 都存⼀个 key 下,产⽣ bigkey。

    bigkey 的优化

数据分段存储,⽐如⼀个⼤的 key,假设存了1百万的⽤户数据,可以拆分成 200个 key,每个 key 下⾯存放5000个⽤户数据。

  • 优雅的处理方式

如果 bigkey 不可避免,也要思考⼀下要不要每次把所有元素都取出来(例如有时候仅仅需要 hmget,⽽不是hgetall),删除也是⼀样。

适合的数据类型

要合理控制和使⽤数据结构,但也要注意节省内存和性能之间的平衡。

// 例如以下的设置可以进⾏优化
set user:1:name tom
set user:1:age 19
set user:1:favor football
// 使⽤ hash 来保存
hmset user:1 name tom age 19 favor football

控制 key 的⽣命周期

建议使⽤ expire 设置过期时间(条件允许可以打散过期时间,防⽌集中过期)。

命令的使用

O(N) 命令关注 N 的数量

如 hgetall、lrange、smembers、zrange、sinter 等并⾮不能使⽤,但是需要明确 N 的值。有遍历 的需求可以使⽤ hscan、sscan、zscan 代替。

禁⽤命令

禁⽌线上使⽤ keys、flushall、flushdb 等,通过 redis 的 rename 机制禁掉命令,或者使⽤ scan 的⽅式渐进式处理。

合理使⽤ select

redis 的多数据库较弱,使⽤数字进⾏区分,很多客户端⽀持较差,同时多业务⽤多数据库实际还 是单线程处理,会有⼲扰,redis 分了 16 个 db,其实这 16 个 db 也是使⽤⼀个线程来处理。

使⽤批量操作提⾼效率

原⽣命令:例如 mget、mset,⾮原⼦操作。
⾮原⽣命令:可以使⽤ pipeline 提⾼效率,原⼦操作,可以打包不同的命令,需要客户端和服务端 同时⽀持。
但要注意控制⼀次批量操作的元素个数(例如500以内,实际也和元素字节数有关)。

Redis事务功能

不建议过多使⽤,可以⽤ lua 替代。

过期策略

Redis 对于过期键有三种清除策略:
1)被动删除:当读/写⼀个已经过期的 key 时,会触发惰性删除策略,直接删除掉这个过期 key。
2)主动删除:由于惰性删除策略⽆法保证冷数据被及时删掉,所以 Redis 会定期主动淘汰⼀批已 过期的 key。
3)当前已⽤内存超过maxmemory 限定时,触发主动清理策略。
主动清理策略在 Redis 4.0 之前⼀共实现了 6 种内存淘汰策略,在 4.0 之后,⼜增加了 2 种策略, 总共8种:
针对设置了过期时间的 key 做处理:
1)volatile-ttl:在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进⾏删除,越早 过期的越先被删除。
2)volatile-random:就像它的名称⼀样,在设置了过期时间的键值对中,进⾏随机删除。
3)volatile-lru:会使⽤ LRU 算法筛选设置了过期时间的键值对删除。
4)volatile-lfu:会使⽤ LFU 算法筛选设置了过期时间的键值对删除。
针对所有的 key 做处理:
5)allkeys-random:从所有键值对中随机选择并删除数据。
6)allkeys-lru:使⽤ LRU 算法在所有数据中进⾏筛选删除。
7)allkeys-lfu:使⽤ LFU 算法在所有数据中进⾏筛选删除。
不处理:
8)noeviction:不会剔除任何数据,拒绝所有写⼊操作并返回客户端错误信息”(error) OOM command not allowed when used memory”,此时 Redis 只响应读操作。
LRU 算法:(Least Recently Used,最近最少使⽤),淘汰很久没被访问过的数据,以最近⼀次 访问时间作为参考。
LFU 算法:(Least Frequently Used,最不经常使⽤),淘汰最近⼀段时间被访问次数最少的数 据,以次数作为参考。
当存在热点数据时,LRU 的效率很好,但偶发性的、周期性的批量操作会导致 LRU 命中率急剧下降,缓存污染情况⽐较严重。这时使⽤ LFU 可能更好点。
根据⾃身业务类型,配置好 maxmemory-policy(默认是noeviction),推荐使⽤ volatile-lru。如果不设置最⼤内存,当 Redis 内存超出物理内存限制时,内存的数据会开始和磁盘产⽣频繁的交换 (swap),会让 Redis 的性能急剧下降。 当 Redis 运⾏在主从模式时,只有主结点才会执⾏过期删除策 略,然后把删除操作“del key”同步到从节点删除数据。

问题一:简单描述一下 Redis哈希槽的概念?

Redis集群没有使用一致性hash,而是引入了哈希槽的概念,当需要在 Redis 集群中放置一个 key-value 时,根据 CRC16(key) mod 16384的值,决定将一个key放到哪个桶中。

问题二:什么是通用SQL函数? 都有那些?你用过那些 可以简单说下是干嘛的吗 ?

CONCAT(A, B) – 连接两个字符串值以创建单个字符串输出。通常用于将两个或多个字段合并为一个字段。
FORMAT(X, D)- 格式化数字X到D有效数字。
CURRDATE(), CURRTIME()- 返回当前日期或时间。
NOW() – 将当前日期和时间作为一个值返回。
MONTH(),DAY(),YEAR(),WEEK(),WEEKDAY() – 从日期值中提取给定数据。
HOUR(),MINUTE(),SECOND() – 从时间值中提取给定数据。
DATEDIFF(A,B) – 确定两个日期之间的差异,通常用于计算年龄
SUBTIMES(A,B) – 确定两次之间的差异。
FROMDAYS(INT) – 将整数天数转换为日期值。

问题三:简单描述一下Redis读写分离模型?

通过增加Slave DB的数量,读的性能可以线性增长。为了避免Master DB的单点故障,集群一般都会采用两台Master DB做双机热备,所以整个集群的读和写的可用性都非常高。
读写分离架构的缺陷在于,不管是Master还是Slave,每个节点都必须保存完整的数据,如果在数据量很大的情况下,集群的扩展能力还是受限于单个节点的存储能力,而且对于Write-intensive类型的应用,读写分离架构并不适合。

问题四:MySQL数据库作发布系统的存储,一天五万条以上的增量,预计运维三年,怎么优化?

a. 设计良好的数据库结构,允许部分数据冗余,尽量避免join查询,提高效率。
b. 选择合适的表字段数据类型和存储引擎,适当的添加索引。
c. mysql库主从读写分离。
d. 找规律分表,减少单表中的数据量提高查询速度。
e. 添加缓存机制,比如memcached,apc等。
f. 不经常改动的页面,生成静态页面。
g. 书写高效率的SQL。比如 SELECT * FROM TABEL 改为 SELECT field_1, field_2, field_3 FROM TABLE.