文章目录一、概念简述1.1 为什么要用缓存二、缓存的更新策略2.1 定期生成2.2 实时生成三、内存淘汰策略四、缓存预热五、缓存常见问题5.1 缓存穿透5.2 缓存雪崩5.3 缓存击穿5.4 三个问题对比六、分布式锁6.1 概念6.2 基本实现SETNX6.3 问题一程序崩溃锁没释放死锁6.4 问题二误删别人的锁6.5 问题三校验 删除不是原子的6.6 问题四过期时间不好设置——看门狗6.7 问题五Redis 本身挂了——Redlock七、总结一、概念简述Redis 最主要的用途有三个方面缓存、存储数据作为数据库、消息队列。其中缓存是使用最广泛、也最容易出问题的场景本文重点讲缓存的相关问题以及在分布式系统中非常重要的分布式锁。1.1 为什么要用缓存MySQL的数据存在硬盘上容量大但读写速度慢并发能力有限而且数据库是非常娇嫩的请求一多就容易扛不住。Redis的数据存在内存上速度快并发高但内存空间有限存不下全部数据。而实际业务中数据的访问存在明显的热点往往 20% 的数据承载了 80% 的访问量二八定律。所以只需要把这部分热点数据放到 Redis 里就能挡住绝大部分请求保护后面的 MySQL。那么问题来了怎么知道哪些是热点数据这就涉及缓存的更新策略。二、缓存的更新策略缓存的更新策略有两种定期生成和实时生成。2.1 定期生成以搜索引擎为例通过日志统计出热点词再把热点词对应的搜索结果提前放进缓存通过日志的形式记录用户都搜索了哪些词。日志存储在HDFS等分布式存储中。对搜索词进行统计、排序得到热点词。统计的周期可以是一天、一周或者一个月。把热点词涉及的搜索结果拎出来放到缓存中。把上面的统计过程写成一套离线流程然后通过定时任务来触发。特点缓存里的内容是提前算好的命中率有保证但实时性差只有定时任务跑完才会更新。2.2 实时生成请求来了先查 Redis命中直接返回。没命中去查 MySQL查到之后再写入 Redis下次就可以命中了。特点实时性好不需要额外的统计流程但是缓存刚开始是空的所有请求都会打到 MySQL这个问题在后面的缓存预热中解决。随着数据越写越多内存会不够用这就需要内存淘汰策略来决定淘汰谁。三、内存淘汰策略Redis 的内存是有限的。当内存不足以容纳新写入的数据时就需要把一些旧数据淘汰掉。常见的淘汰算法有四种FIFOFirst In First Out先进先出把缓存中存在时间最久的也就是先来的数据淘汰掉。LRULeast Recently Used淘汰最久未使用的记录每个 key 的最近访问时间把最近访问时间最老的 key 淘汰掉。LFULeast Frequently Used淘汰访问次数最少的记录每个 key最近一段时间的访问次数把访问次数最少的淘汰掉。Random 随机淘汰从所有的 key 中抽取幸运儿随机淘汰掉。Redis 提供了配置项来选择淘汰策略maxmemory 1gb# 最大内存maxmemory-policy allkeys-lru# 淘汰策略也可以运行时动态修改config set maxmemory-policy allkeys-lru。一共有 8 种策略名字由淘汰范围 算法组成策略淘汰范围说明volatile-lru设置了过期时间的 key使用 LRU 算法淘汰allkeys-lru所有 key使用 LRU 算法淘汰volatile-lfu设置了过期时间的 key4.0 新增使用 LFU 算法淘汰allkeys-lfu所有 key4.0 新增使用 LFU 算法淘汰volatile-random设置了过期时间的 key随机淘汰allkeys-random所有 key随机淘汰volatile-ttl设置了过期时间的 key按剩余存活时间越早过期的越先淘汰noeviction——默认策略。内存不足时不淘汰新的写入操作会报错细节Redis 里的 LRU 和 LFU 都是近似算法。精确的 LRU 需要维护所有 key 的访问顺序开销太大所以 Redis 采用随机采样每次随机抽几个 keymaxmemory-samples默认 5 个淘汰其中最合适的。过期删除和内存淘汰是一回事吗不是。过期删除针对的是已经到了过期时间的 keyRedis 采用惰性删除 定期删除的组合内存淘汰针对的是内存已经满了哪怕 key 还没过期也要挑一些淘汰掉是内存不足时的兜底手段。四、缓存预热如果只用实时生成Redis 服务器刚接入的时候是空的没有任何数据此时所有的请求都会打到 MySQL 上MySQL 的压力会非常大。刚开始怎么让 MySQL 的压力小一些把定期生成和实时生成结合起来在系统上线前先通过统计的途径把已知的热点数据提前导入 Redis这就叫缓存预热系统运行后再靠实时生成不断写入新数据随着时间推移让新数据淘汰旧数据。五、缓存常见问题缓存的使用中有三个非常经典的问题缓存穿透、缓存雪崩、缓存击穿。它们的共同点是请求没有被缓存挡住直接打到了数据库上造成数据库压力骤增甚至宕机。5.1 缓存穿透查询的某个 key在 Redis 中没有在 MySQL 中也没有。这个数据根本不存在所以永远不会被写入 Redis如果一直有人这么查每一次请求都会打到 MySQL 上。为什么会出现这种情况①业务设计不合理或者缺少参数检查比如查一个不可能存在的 id。②误操作比如把数据库里的数据误删了。③黑客恶意攻击故意构造大量不存在的 key 来请求。怎么预防缓存空值如果发现 key 在 Redis 和 MySQL 中都不存在仍然把它写到 Redis 中value 设置成一个空值如空字符串或者特殊的占位值。下次再查就能在 Redis 里命中。注意要给空值设置一个较短的过期时间避免攻击者用海量不同的 key 把 Redis 内存撑满。布隆过滤器每次查询 Redis 和 MySQL 之前先判断一下 key 是否存在于布隆过滤器中不存在就直接返回。使用前需要把数据库中所有的 key 都插入到布隆过滤器里。参数校验和限流对明显非法的参数直接拦截对异常高频的请求限流、拉黑。布隆过滤器是什么有什么特点它由一个很长的bit 数组和多个 hash 函数组成。插入 key 时用多个 hash 函数算出多个位置把这些位置都置为 1查询时这些位置全部为 1才认为可能存在。特点① 判断不存在那么一定不存在② 判断存在可能是误判hash 冲突导致③ 占用空间非常小④ 普通的布隆过滤器不支持删除。用在防穿透上误判只会让极少量的无效请求漏过去不影响正确性。5.2 缓存雪崩在短时间内Redis 上大规模的 key 失效导致缓存命中率急剧下降MySQL 的压力迅速上升甚至宕机。为什么会出现这种情况①Redis 宕机缓存整个没了所有请求直接打到 MySQL。②大量 key 的过期时间相同给 key 设置过期时间时时间设得一样到点之后同时失效。怎么预防加强监控报警提高 Redis 的可用性主从 哨兵、集群避免 Redis 整个挂掉。不给 key 设置过期时间配合前面的内存淘汰策略内存满了自动淘汰适用于数据不会过时的场景。给过期时间添加随机因子比如基础时间 一个随机值让 key 的失效时间分散开不要同一时刻集中失效。5.3 缓存击穿指某一个热点 key突然过期导致大量并发请求同时打到数据库上。它和雪崩很像区别是雪崩是大量 key同时失效击穿只是一个或少数几个热点 key失效但这个 key 的访问量特别大。怎么处理热点 key 永不过期基于统计的方式参考上面的定期生成发现热点 key把它们设置成永不过期。互斥锁分布式锁key 过期后让只有一个请求去查数据库并回填缓存其他请求等待或重试。通过分布式锁来限制对数据库的访问频率下一章详细讲。服务降级适当关闭一些不重要的功能只保留核心功能类似手机、电脑里的省电模式。不是关闭服务而是保证核心业务不被拖垮。5.4 三个问题对比问题现象根本原因主要对策缓存穿透查的 key 在缓存和数据库里都不存在请求的数据根本不存在缓存空值、布隆过滤器、参数校验缓存雪崩大量 key同时失效或 Redis 宕机过期时间集中、Redis 不可用过期时间加随机值、高可用、监控缓存击穿一个热点 key过期并发请求打到数据库热点 key 失效永不过期、互斥锁、服务降级六、分布式锁6.1 概念锁本质是解决并发下的问题。但之前学的锁比如mutex只能在进程内部生效无法对分布式系统中不同服务器上的进程产生制约。所以引入了分布式锁。举个例子有多台服务器处理订单每一台都要先查余票、再扣减余票。这两步不是原子的如果多台服务器同时操作就会出现超卖。必须保证同一时刻只有一台服务器能执行这段逻辑。所谓的分布式锁也是一个/一组单独的服务器程序给其他的服务器提供加锁这样的服务。Redis是典型的可以用来实现分布式锁的方案但不是唯一的。业界也会使用MySQL、ZooKeeper、etcd这样的组件来实现。为什么不直接用 MySQL 事务来解决用 MySQL 事务确实可以解决查询 修改这样的问题。但在分布式系统中访问的共享资源不一定是 MySQL也可能是其他没有事务功能的存储介质或者是一些其他的特定操作这时就需要一个通用的、独立于具体资源的锁。6.2 基本实现SETNXRedis 中有一个setnx命令set if not existskey 不存在才设置成功。利用它就能实现加锁加锁setnx lock_key value。返回成功表示加锁成功返回失败说明别人已经持有锁。解锁del lock_key。但这样简单的实现有一系列的问题。下面一个一个解决。6.3 问题一程序崩溃锁没释放死锁如果加锁成功了但后续逻辑中程序崩溃了没有执行到解锁怎么办这把锁就会一直存在别人永远拿不到形成死锁。ZooKeeper的临时节点天然可以解决这个问题解决给锁设置一个过期时间到时间自动删除不会一直锁着。错误的方式setnx lock_key value expire lock_key30这是错误的这是两条命令无法保证原子性如果setnx成功后在执行expire之前程序崩溃了或网络断开过期时间就没设上锁仍然会一直存在。正确的方式使用一条命令同时完成设置值和过期时间务必使用下面的方式setlock_key唯一标识NX EX30NXkey 不存在才设置EX 30过期时间 30 秒。返回OK表示加锁成功返回(nil)表示失败。6.4 问题二误删别人的锁服务器 1 加的锁被服务器 2 给解锁了怎么办正常来说不应该有这样的操作通常是代码有 bug。更常见的情况是服务器 1 的业务执行太久锁已经过期自动释放被服务器 2 拿到了服务器 1 执行完后又去del把服务器 2 的锁给删了。另外一个服务器内部也可能是多线程的两个线程在执行解锁时同样会出问题。来看一个具体的例子重复执行del好像问题不大实则不然一旦引入一个新的服务器去加锁就会出问题① 线程 A 执行完del锁被释放。② 服务器 2 的线程 C 正好要加锁set由于锁已经释放了C 加锁成功。③ 紧接着线程 B 的del才到来把刚刚服务器 2 加的锁给解开了。④ 此时 C 以为自己还持有锁实际锁已经没了别人又可以加锁互斥失效。解决引入唯一标识解锁前先校验。加锁时value 不再随便写而是写入当前持有者的唯一标识比如服务器编号 线程 id或者一个 UUID。解锁时先检查 value 是不是自己的标识是才能删除。6.5 问题三校验 删除不是原子的校验的流程是先get判断再del。但这两步不是原子的get判断通过之后、del执行之前锁可能刚好过期被别人拿到这时del又会把别人的锁删了。解决让判断 删除变成原子操作。方案一Redis事务MULTI / EXEC。方案二Lua 脚本实践中更常用。Redis 会把整个 Lua 脚本当作一个整体来执行期间不会被其他命令打断。ifredis.call(get,KEYS[1])ARGV[1]thenreturnredis.call(del,KEYS[1])elsereturn0end调用方式eval上面的脚本1lock_key自己的唯一标识6.6 问题四过期时间不好设置——看门狗锁的过期时间很难设置一个合适的值设置太短业务还没做完锁就释放了别人趁虚而入。设置太长如果持有者崩溃锁要等很久才能释放其他人一直等待。解决动态续约看门狗机制Watch Dog。初始情况下设置一个比较短的过期时间比如 30 秒。如果当前任务还没有执行完就在快到期时把过期时间再续上比如每隔过期时间的 1/3 检查一次没做完就续约。如果服务器中途崩溃了自然就没有人负责续约了此时锁就能在较短的时间内被自动释放。这个机制需要专门的线程来负责续约这种线程就被叫做看门狗。比如 Java 里的Redisson就内置了看门狗6.7 问题五Redis 本身挂了——Redlock使用 Redis 作为分布式锁Redis 本身有没有可能挂呢很有可能。所以需要保证 Redis 的高可用做好预案。分布式锁涉及的数据量并不大所以可以用主从复制 哨兵来解决高可用。但是这里有一个非常严重的问题主从复制是异步的有延迟。比如客户端 A 在主节点上加锁成功。这个加锁的数据还没来得及同步给从节点主节点就挂了。哨兵把从节点提升为新的主节点但这个节点上没有这把锁。客户端 B 来加锁也成功了。此时A 和 B 同时持有同一把锁。Redis 的作者给出了解决方案Redlock 算法。它的本质是冗余通常提到冗余都和高可用有关。做法部署多个通常是 5 个相互独立的 Redis 节点它们之间不是主从关系各自都是独立的主节点。客户端依次对这些节点都进行加锁操作用的就是上面的set NX EX。某个节点挂了不要卡住继续给下一个节点加锁就行。如果加锁成功的节点数量超过总数的一半5 个里至少 3 个并且总耗时没有超过锁的有效期就视为加锁成功。解锁同理对所有节点都执行解锁操作。细节Redlock 在业界是有争议的比如它对时钟漂移、进程长时间停顿这类情况比较敏感。如果对锁的强一致性要求特别高更推荐使用ZooKeeper或etcd这样的方案。七、总结本文讲了 Redis 最常见的两个应用缓存缓存通过热点数据挡住大部分请求保护数据库。更新策略有定期生成和实时生成内存满了靠淘汰策略冷启动靠缓存预热。三大问题穿透数据不存在、雪崩大量 key 失效、击穿热点 key 失效。分布式锁一步步演进的过程也是一个发现问题 → 解决问题的过程问题解决办法加锁后程序崩溃死锁设置过期时间用set NX EX一条命令保证原子性误删别人的锁value 存唯一标识解锁前校验校验 删除非原子使用 Lua 脚本过期时间不好设置看门狗动态续约Redis 自身故障、主从切换丢锁Redlock 算法多个独立节点过半成功非常感谢您能耐心读完这篇文章。倘若您从中有所收获还望多多支持呀