我们现在开始讲第8周的内容
第8周主要是讲一下分布式缓存和分布式事物
我们前面的两个案例
一个评论一个播放历史两个系统
有了这些系统架构的设计经验
实际上在我们后端编程
或者说在我们的一个高可用架构里面
非常重要的一个点
就是分布式缓存和分布式事物的设计
因为我们的应用它本身是一个无状态的
也就是说它的所有的接口请求都是做一些业务逻辑的处理
那么它真正带状态的地方是哪里
一个就是分布式缓存
还有我们的数据库
这两块会变得非常重要
那么如果搞定了
我们通过基础库和可用性的一些框架设计
有些兜底的一些能力
但是实际上一个系统最核心的部分
也就是它的存储的设计部分
那么存储我们其实展开讲
就是包含易失性的存储和持久化的存储
那么一个是缓存
一个我们的像数据库 kv这种
它可以做永久保存数据的这种
我们叫持久化存储
这两块会设计是非常重要的
也是我们其实在工作也好 还是在面试也好
其实这两块都会被面试官
或者说被我们系统设计里面用的非常多的
这块应该是要认真反复去阅读我们课后去查看一些reference
以及参考一些别人怎么去用缓存去做一些
结合我们系统去做一些设计的
我们先从缓存的整个引进来说
我们分为几块来讲
一个就是选型
我们从早期的一个单体的架构
从B站很早期的阶段
到我们现在一个阶段
我们大概是怎么演进的
第二块就是我们缓存的模型一些模式
类似像怎么解决热点缓存等等
还有一些我们常用的缓存的技巧
当然还包含一些像一致性缓存和数据库的一致性问题
其实这个问题也会被问得非常多
选型
我们第一个还是要介绍最常见的就是memcache
不知道大家有没有用过
因为大家现在基本上都是用再用redis
我们先简单回顾一下
那么memcache它提供的是简单的
纯kv的一种cache存储
我们最简单的kv key-value这种
我们以前知道就是memcache最基础的一种模型就是key-value
那么value它有限制是不能超过1M的
也就是说如果你的value特别大 它会报错
memcache底层可能会直接拒绝掉这个请求
那么为什么我们当时在b站的早期架构会引入memcache
我们当时主要是使用memcache作为一个大文本
像评论内容弹幕的一些文件集合啊
或者说非常简单的这种kv结构我们就会使用memcache
当时是怎么考虑的
memcache它有个优势
它是多线程的
所以说它整个吞吐会比较不错
吞吐不错指的是我再取一些大文本的时候
因为它的整个value的size是比较大的
那么取大文本的时候它是有优势的
它不容易出现
因为我在redis里面取一个 大文本
或者是我做一个复杂的查询操作
比方说redis里面自己认知
这种操作它非常大
那么一旦操作特别大的时候
会导致我们整个redis的吞吐它降得非常厉害
尤其像redis早期版本
它是一个单进程单线程的一个模型
所以这个问题会凸显得非常厉害
所以我当时考虑的时候
也是选型的时候使用memcache
当然我也参考了行业里面的一些文章
像当时我看到像微博的一个微博的关系链的设计
我就参考了他们的一个做法
我们的关系链数据库
关系链就是你关注我关注你
我们当时就使用了memcache在redis前面还挡了一层
后面我再简单说一下这个case
那么memcache它的内存管理
使用的一个叫slab的方式做内存管理
大家可以简单理解为分块
跟Go的内存模型
就是内存的内存池实现有点类似
举个例子 我的块是从20字节40字节60字节
我随便举个例子
20 40 60 80
假设它的增长因子就是20个字节
那么如果我现在要塞入一个16个字节的一个item
是不是就选择20 40 60
我是不是可以做一个二分查找
找到一个离它最近的一个窗口
或者叫一个slab
然后我会去把item放进去
那么这个地方它应该是选择20的slab去存储
那么这样的话其实是稍稍有一些浪费
就浪费了多少呢
我们20-16浪费了4个字节
所以这个是要注意的
就是因为它内存池的话
它提前预分配好一个一块内存
比方说分配1M内存
把它切割成20字节
切成好多个块
那么你用的时候其实就挑
是因为你切成很多块以后
我觉得其实就是从块里面捞一个出来
用完之后再释放掉
放回到我们的一个空闲列表里面
其实是memcache它核心的一个设计原理
大家也可以去看一下它整个内存池的一个实现
我记得我刚工作的时候去学内存池
了解什么是内存碎片
从操作系统内存碎片
了解到为什么要做内存池
慢慢其实把一系列展开的文章都去阅读了
我在这里会举我们memcache的一个内存去设计
那么我还建议可以参考一下像nginx
像tcmalloc库
也就是Go的内存池的实现
甚至内核的一些内存池的实现
你其实都可以作为一个参考
作为我们扩展阅读
去了解不同一些组件它们内存池的一些优化和做法
因为memcache的新版本
它支持了一个叫做slab的一个增长因子
我刚刚说的是20字节增长 对吧
20 40 60
但是它一般来说
这个增长因子是个系数1.5倍两倍
这个是可以自己去调整的
那么还有一个功能叫做slab_automove和slab_reassign
叫动态或者手动的去move一些slab
为什么会有这个功能
我就举一个非常有意思的case
我们当时有一个场景是存储一些验证码的图片
大家都可能做过验证码
会出一些这种字符
就比方说让你填个5432一个歪歪倒的这种数字
我们当时是把用PNG生成之后存到memcache里面
那么先随机选几个数字
然后会直接把这个图给从memcache取出来
就避免去动态生成了
做完这个功能以后
我们发现
因为你的数字它是组合的
所以它会存很多相似的item
因为那个图片基本上都是类似的
加一些噪点
加一些数字生成一个png
它就密集到某一个slab里面
举个例子
这个图片假设是80个字节
大家都会去选80个字节这一个slab
也就是说memcache假设一共有512M
那么它因为生成大量的这种随机组合的小图片
它都去某一个slab里面了
我们知道memcache的机制
申请一块内存
然后做切割
也就是说你这会儿大部分的内存去哪了
命中到全部按80字节这个来切割的
就是用80字节切割
就是slab的类
我们叫slab的一个class 一种分类
也就是说你内存全部按大小切割了
这样会出一个什么问题
你混用memcache塞一个其他size
比方这会塞一个160或者是320的一个item
一看memcache告诉你有空闲内存
但是实际上你去塞320或者160自己的item的时候
它会导致以前那些图片的item
我们当时设计的时候肯定是让它永不过期
因为这种图片我们不希望每次去cache mredis重新生成
所以我们一般都是永不过期
我们会发现当塞入160或者32字节这种item的时候
它会导致之前slab的那些对象被LRU踢掉
我们当时就踩了这个坑
为什么会出现这种现象呢
也就是因为你之前memcache的这些内存都
被拿来按照80slab去割接用掉了
那么实际上你去放其他slab的时候
已经没有任何空闲内存了
那么这个时候会触发LRU就会把之前的一些图片给踢掉
直到能够踢掉足够多的一些对象
那么直到能够满足320或者16字节slab
就是留出可以给它切一整块的这么一个量出来
就导致了我们的图片丢失
所以后来memcache一个版本
有一个automove
你可以把之前的一些这种slab不均匀
你可以把它挪一挪
但是在我们那个场景里面
因为还不能去挪
不是特别好
应该是要把这两种场景给分开去使用会比较好
所以我们当时是踩了这个坑
大部分情况下我们就简单地用它的kv功能
我是非常推荐用memcache的
因为它的吞吐和latency的响应都是非常好的
所以我这里就一笔带过
有一些主流的基本上还是用redis
但是我们还是要回溯一下
memcache我们刚才讲了
每个slab它是包含若干大小的1M的内存页
也就是说我是用1M的一个内存大小
把这个1M按照一个我们叫slab class就是第0类
我们看鼠标标识的地方
你看到我们这个地方按照这个slab class
第0类就是第一个它是88字节的
我们看这个字节是88字节
slab class就01234它有很多个种类
那么这个时候相当于1M的slab
它被切割成很多块对吧
我们每一个块叫一个chunk
那么每个chunk实际上就可以存储一个item
那么在memcache启动的时候
每个slab都会预分配一个1M的内存
也就是说每一个都给你来一点
然后每一个slab的增长因子是通过-f这个去指定的
它默认是1.25
也就是说88×1.25是下一个slab class大小
也可以设置它的起始值
起始是48字节
我刚刚讲了内存池有很多设计可以参考
我标出来的像nginx的ngx_pool_t
还有像tcmalloc
它的设计其实我觉得大同小异
你了解过nginx或者是memcache这两类的话
基本上你大部分的内存池的设计你就都能够掌握了
像我做过个goim的时候
我就自己用Go的一个特性
自己做了一个内存池
这样的话我可以申请一个超大的内存
比方说100M
那么我再把这100M按照刚刚那种思路
把它切割成不同分类的一个大小来使用也是可以的
所以很容易做这种优化
因为每次用一个40字节 50字节
每次都去在我们Go里面
你都要去make自己的数组
其实它就产生了很多小对象
一旦你分配一个超大的内存
然后自己再手动去切割的话
其实只有一个对象
那么它GC的压力就小得多
memcache里面它最终slab class它有个叫slots的一个指针
因为我们知道memcache你用完了以后
因为它会被LRU或者说因为出现TTL过期了
那么它会触发的LRU回收
回收空闲的item
它就会挂载到slots这个指针
那么它其实是个链表
你用的时候其实就是从链头取掉一个
你放回去的时候就追加上去就好了
所以比较简单
那么分配出去的item列表
其实也可以按照LRU去进行一个排序
就是按照已经分配出去的
你可以按照LRU访问频次用链表
我们把它推到队列的尾部或者是头部
这样的话实现一个LRU
这个是它大致的一个原理
我们要去自己也可以去看一下相关的一些文章
我就不展开细节慢慢去讲
我应该是好早以前看过它的源码
我们再看看redis
其实现在基本上是非常主流的cache的中间件
那么redis它有几个优势
第一 它有丰富的数据类型
支持增量方式的修改
部分数据什么叫增量
我们知道像sorted set和hashs list它都支持push zadd hset
去可以对一个已有的类型的key
你可以去做一些追加的一个操作
那么对我们有些业务场景里面事先实现像类似排行榜
集合 数组 hashmap它都可以很方便去支持
我觉得这个是一个很大的优势
比方说我们上两节课讲的像评论和播放历史
实际上都使用了redis作为数据索引
注意是数据id的一个索引
我们像评论列表的id播放历史的列表id这两个id的集合
我们用追加的方式去维护它的cache
包括像我们的关系链
我们的关系链的一个列表的id
因为redis默认是很早期版本是没有内存池的
所以它是存在一定的内存碎片
当然了它新版本很早以前就支持叫jemalloc来优化内存分配
编译的时候它是默认用jemalloc来替换掉glib的malloc使用
来替换掉默认的glib malloc
这个其实是它针对内存池
其实它做的会比较简单
就没有自己去做内存池
而是用底层的jemalloc库
来解决内存分配的这种碎片问题
从我的视角看redis和memcache它的最大区别
还是我刚刚讲的就是redis是单线程
但我们知道redis现在它其实已经改成了双线程
一个线程处理负责io
io指的是网络协议的输入和输出
另外一个现成负责数据的读写 数据的这种存储
memcache它天然是多线程的
所以QPS你去压测的时候
实际上感觉差异不会大
大概来说单节点都是在10万左右
但是吞吐会有比较大的差别
大家可以去压测试试
你作为redis benchmark的时候
你把key-value的size变得越来越大以后
redis它的吞吐其实会下降得厉害一些
整个它的数据的存储的线程是一个单线程工作模型的
会导致其他查询在前一个查询没处理过的还没返回的时候
你的后续的查询会pendding住 就会堵住
所以其他查询进不来
那么这个时候它的QPS会抖动得非常厉害
当然了因为网络导致的一些这种瓶颈
它也会导致抖动的厉害
那么新版本把io隔离出来以后
会有不少的改善
但是对于一些大value的查询还是会有有一定的影响
建议纯kv的话走memcache
其实这一块我又有些变化
我觉得现在如果你用redis来做
它的单kv的查找也是问题不太大的
因为它双线程优化之后会比之前好很多
那么你可以在实际的场景里面
根据你的key映射的value的size的大小去做一下banchmark看一看
和memcache对比下来
它吞吐会下降多少
对这个也可以去关注一下
所以举个典型例子
大家可以看一下微博的关系链的缓存设计
那篇文章很经典
我们当时做的时候也参考了他的一个思路
我们可以想象一下
我们做关系链的时候非常适合什么
用户id做key
然后把你的关注列表的这些数据放到一个hashs
hashs里面有自己的field和value
那么去存储双向关系
那么failed实际上就是你关注的人的id
那么为value你可以放一些类似像关注时间
你们俩是什么悄悄关注之类的
可以放一些别的一些属性
我们当时是这样来放的
那么我们可以想象一下你的关注关系服务
它有一些什么样的功能
一般来说我们用关系链判断我们俩是什么关系
传入我的id以及你的id
然后我可以调一个hget对吧
key就是用户我的用户id
那么hget的另外一个字段
其实就是你想查询的另外一个id跟你是什么关系
所以你调一个hget就可以了
用hashs是非常符合这种点查关注关系链的一个场景
但是我们有些场景可能会去做更复杂的一些查询
举个例子来说
我假设有一些需求是要返回我关注的人里面谁关注了他
那么我关注的人是不是要调hashs把它全取出来
然后逐个遍历
我关注的人里面谁关注的他
就是逐个遍历和那个id
你要去判断哪些是关注他
那么这样的话因为你全取
会导致redis整个吞吐其实下降的是非常厉害的
所以我们当时设计的是双缓存架构
在redis上层
还是挡了一层memcache
方便全取就是把整个关注列表的hashs的里面的数据
用protobuf序列化以后再存一个key value放到memcache
那么这样我在缓存的更新的时候
其实是要双更新双删除的
那么这样的话会避免hgetall导致的吞吐下降的一些问题
我们在系统中其实也是多次使用
memcache加redis双缓存的一个设计
当然我现在觉得如果你用redis的key value问题也不大
就是用redis string
因为我们value序列化以后的size其实并不会特别大
因为你像我们关注列表
一般一个用户最大的关注可能1000~1500
那么你这个时候把它序列化成一个pb的数组
其实也不会特别大
那么你放到redis的string
我觉得是可接受的
那么它吞吐也不会特别影响
这样的话有个好处就是我减少了维护两个组件
长期来看我们是只使用redis
只要你这个value不是大的特别夸张
我觉得可能影响不会特别大
当然我们因为历史原因
还有很多memcache去解决这种redis吞吐的一个问题
那么这个是关于redis和memcache
这个还是要了解一下
我们再看一下
我们知道我其实之前遇到很多公司
它的redis很大的一个问题就是在于混用
我们之前讲隔离也好 讲微服务也好
都提到一个点
我们叫数据要我们去中心化
也就是说作为一个微服务
它的数据存储节点
缓存节点应该是独占的
只跟自己绑定
每个微服都是自己的redis
那么这样也至少有个什么好处呢
我的redis吞吐其实上跟每个微服务就是去绑定了
那么以前只有一个redis
所有的微博都去使用
那么它吞吐10万其实有可能就不够了
或者说因为内存的原因等等
那我这样的话
我每一个微服务的相互都是隔离的
影响也会更小
那么我的吞吐也会更好一些
这是第一个层面隔离
那么第二个层面
随着我的redis使用的内存越来越多
qps查询越来越高
可能一个节点不能满足我的内存增长的需求
可能要分布在多个节点 多个物理机上
甚至是它的QPS不满足我的需求
我可能要扩展多个实例来进一步提升我的qps查询的能力
这个时候我们一般会搭建多个redis节点
那么我们知道搭建多个redis节点
其实就涉及到数据分片的问题
我们叫数据sharding
我们第一个阶段其实早期其实说白了就是单个redis
每个微服务用一个
但是随着我们规模变大
我们其实第二个阶段就是
使用了redis的一个多个下的就搭建多个redis
但是hash的策略我们不太想自己在Go的redis
Go redis或者是redis client SDK里面去维护这种sharding的逻辑
所以我们引入了一个Proxy
当时看到开源的
其实早期就是应该是推特开的一个叫twemproxy的一个代理
这个代理它在时限上也是一个类似像redis一样
一个单进程单线程的模型
但是它有好处
这个Proxy它可以在twemproxy里面
可以后面配很多个redis节点
我连到突然twemproxy的缓存代理以后
我塞一个key 它会根据key做hash
然后命中到其中的任意一个redis节点
那么我这样的话其实我就把它sharding出去了
使用代理的好处
而且我的客户端也不用做改造
因为Proxy也是redis协议 我可以直接连接
当时它其实是用的一致性哈希的一个算法
那么它的实现上这个twemproxy
一个坑就是说它是单进程单线程模型
所以在处理大的时候可能会有io的一个瓶颈
这个是我们当前遇到的
第二就是说二次开发成本难度比较高
因为它是用纯C写的
公司内部的一些运维平台
做一些集成加指标加日志都要模改代码
其实也是一个点
第三个其实它不太支持自动扩缩容
举个例子
大家可能就会明白这个场景
一开始我们有三个节点
在twemproxy后后面我们挂了三个节点
我假设set一个key a它命中的节点a
那么这个时候因为一些原因
我们有一个节点挂掉了
这个时候它就变成两个节点
我不可能让我整个系统不可用
所以他通过一致性哈希它会把数据重新分布
会分布在剩下的两个节点
这个时候我不可能长线用这两个节点持续工作和服务
我又把之前挂掉的节点可能重启
成功恢复了
我又把它加回去
这个时候其实就会出一个问题了
这个时候会出一些什么问题呢
因为你之前做扩缩绒的时候
老的数据并没有被清理干净
假设哪一天再挂掉这个a节点
那么你之前set a这个key
是一个过期的副本
你会被查询到能理解吗
就是一开始有一个key a它是在节点1
那么挂掉一个节点之后
因为要移植下去重新分片了
它可能在节点2
那么这个时候我重新把这个节点要加进去
这个时候其实你再去查的时候
之前节点a的数据又可以被查到了
因为你没有去删除
因为你之前节点1过期的v1版本的没有被删除
你的新数据在节点2也没被删除
那相当于我整个memcache集群
里面是有多个版本的key存在的
并且因为你的节点产生一些变化
有可能会访问到过去的一些脏数据
其实是非常之不好处理的
非常不好处理的
所以对我们整个rebalance
重新平衡数据
甚至是我得重新扩缩容的redis节点
都带来非常大的一个困难
会导致数据的不一致
所以我们还看了一些业界开源的代理
有codis
还有facebook开源的一个叫mcrouter
这些其实都存在自己的问题
像codis的话他当时的话只是redis协议
然后需要打petch才能去使用
还有像mcrouter
这个它只是memcache协议 纯C开发
也是遇到跟twemproxy一样的一些问题
基于这几个考虑
我们当时就自研了一个叫overlord
overlord的自己的代理
它有些什么功能
我们后面发现了redis它本身redis cluster
也就是它的集群版本出来了
那么集群版本出来以后
我之前说的那些问题
包括节点的增加减少
他都会做数据的一些搬迁
那么有了搬迁
并且它搬迁其实是做的比较好的
就是不太容易出现数据不一致的一个情况
并且redis cluster的每一个shard每一个分片还支持application
所谓的主从主副本和从副本
那么在挂掉一个主的时候
它会自动提升slave为master
这样的话我的数据默认情况下不会随意去搬迁
这是一个
第二就是说我在新加节点的时候
它自己会去均衡
所以我觉得长期来看
我是建议大家每个微服可以直接
对接到redis cluster
那么redis cluster也有相应的Go的sdk
但是我们内部的一些原因我们没有直接使用redis cluster
而是在redis cluster的前面在挂了个代理
为什么
因为redis cluster协议跟原生的redis协议有一些小小的区别
那么为了简化我们当时的一个改造成本
以及尽可能让我们的各种语言接入透明
我们就研发了overlord
overload实际上你可以理解为是在redis cluster
前面挡了一层proxy
然后它是用原生的redis协议
而不是redis cluster的协议
这样的话对客户端的接入使用是非常透明的
我们就可以享用到redis cluster带来的一些这种能力
所以其实对我们来说是一个非常大的进步
缓存做稳定
其实我们内部的事故也大大减少
因为自从使用redis cluster
内部的事故其实是变得非常少
这里面overlord我们还进行一个阶段
我们可以想象一下
正常情况下你去访问一个overlord的代理
你这个代理一般是什么 集中化部署
那么我一个应用去访问一个overlord的
它后面可能挂20个甚至50个redis节点
overload的一个节点虽然它是多线程的
就是用Go写了一个多线程的模型的这么一个代理
但是1:50你代理肯定是扛不住的
有可能要加更多的overlord的节点
那么你加更多的IP进去以后
请问你到底是选择哪一个节点去连接
是不是要用一种4层的负载均衡去
类似像LVS一样去连接到overlord的
哪怕你是用域名的话
其实负载均衡的策略它并不是特别好
所以我们当时还是通过LVS
发到好几个overlord的来做一个流量的4层的一个负载均衡
但是随着我们知道
微服务它其实很重要的一个概念是去中心化
去流量中心化
所以我们其实从以前的这种集中式访问内存
逐渐就变成了一个Sidecar
变成一个Sidecar伴生容器
在我们一个物理机的pod里面
k8s的pod里面
我们应用是一个容器
然后我们再起一个Sidecar伴生容器
那么这个container就是我们的Proxy
这个其实有一点像service mesh的
其实通过这样的一个改造啊
其实让我们的微服务尽量去中心化
第二我们就彻底去掉了LVS
因为其实LVS在我们公司内网基本上就彻底全部去掉了
各个环节都没有4层负载均衡
要么是通过服务发现去找到这些IP
自己做负载均衡
要么就是直接用service mesh
因为这个也不能严格意义上叫service mesh
它其实就是一个cache proxy下沉为一个Sidecar
去中心化去掉LVS
每个容器都是访问本地IPC进程间通讯去连接到代理的
所以说其实是把这个状态或者说把集中式的一个节点
下放到我们的pod里面
这样的话去掉热点
然后Sidecar伴生容器随着我们的App的容器启动而启动
它配置会非常简单
因为你只需要连本地的127.0.0.1的某一个端口就可以了
它会变得非常简单
并且是去中心化的一个方式
所以我们内网其实到现在也是通过App访问
我们的同一个pod里面另外一个container
通过这样一个进程间通讯去简化我们的redis cluster的一个访问
那么讲到redis cluster
或者说我们讲到数据分片
非常典型的其实就是要介绍一下
我们的一致性哈希也会问的比较多
我们前面应该有介绍过我们的历史架构啊
用了一个region sharding
那么region sharding实际上是用key求余
或者说对key进行哈希之后再求余
这种方式我们其实也叫它是哈希求余
那么twemproxy它用的一致性哈希
为什么要用一致性哈希
我们先看一下一致性哈希的算法
你可以理解为它是有一个大的圆环
在环里面我们看一下绿色的表示的是一个节点
叫节点NodeA NodeB NodeC
那么每一个NodeA这个节点
我们用一个希算法得出一个int值
那么这个int值它是一个2的32次方减1
也就是它是个int32
举个例子
我们这个地方ABCD4个节点
其实就是命中这个0~2^32-1的圆环里面的某一个位置
比方说这个地方可能等于三四百
我随便拍一个数字
这个地方可能等于几百万
这个数字可以理解为它是这样一个分布的
就是用希算法对这个节点名称取一个int32
然后得出一个这样的
有了圆环以后
假设我现在要塞一个叫Object B
我要放一个B对象
那么先把B对象用哈希算法求出一个int值
那么这个int值我顺时针查找
看它会命中哪个节点
那么它顺时针查找
对于Object B它会命中的B节点
所以这样的话一致性大概就是这么一个设计
大概这么一个设计
那么这种设计它有个什么好处呢
因为你有求余的做法
在增加服务器的时候
是大部分object都会漂移
也就是说你会加一个节点
你会大量的cache miss
那么一致性哈希其实加一个节点
它的影响面比较小
它只会影响在圆环上增加的节点只会
逆时针方向的第一台服务器受影响
你比方说在这个地方插入一个节点
只会影响这个节点
加入的节点
逆时针方向的这批归它托管了
它只会影响一小批
一致性哈希其实核心就是说
尽量让我们的数据抖动不是那么厉害
对于一个哈希算法
它其实有几个考量指标
一个是平衡性 单调性 分散性和负载平滑性
我就不对着念了
但我们其实核心理解就是说
我们看一下对于我新加入的一个节点Node x
那么是不是影响从节点逆时针方向的到上一个结点
逆时针方向的这些key就归它托管了
这个做法看上去好像似乎不那么平衡也不那么分散
因为你看别的人不受影响
就这两个Node B和C之间这一小波key被影响了
看上去虽然好像变化不会那么的大
怎么说它满足一个相对看起来好像影响不会那么大
但是它又不均衡了
所以这种一致性还是算法的看上去有些缺陷对吧
那么那个算法的设计者非常聪明
它做一个小小的改动
甚至说我们还有一种场景一致性哈希
假设我一致性哈希就两个节点
有没有可能两个节点Node A算一个哈希
和Node B算个哈希
它的两个节点挨得很近
这个地方是一个绿色
这个地方我标出来的地方
那么有可能两个点的时候
Node B有没有可能在这个位置
那是不是对于NodeB来说他好惨
它只有这一小部分的数据可以托管
就是看我箭头这个地方
假设你只能托管这一小部分数据
那么对于Node A它就很爽了
它可以托管了圆环里面大部分数据
它多一点它好像又不太公平
怎么办
必然会导致大量数据集中在Node A上
那么只有极少量的会定位到Node B上
那么为了解决这种数据倾斜的问题
一致性哈希算法它引入了一个叫虚拟节点
虚拟节点的一个机制
什么意思呢
假设我们现在只有Node A和B
我是不是可以给A给它加上一些虚拟节点
那Node A后面加上一个#1
假设我可以虚拟出255个节点
多不多 够多了 255个
然后用同样的哈希算法
在圆环上密密麻麻分布255个
那么Node B同样的密密麻麻分布
那么是不是这样的话
我把一个节点虚拟出n多个出来
然后只要保存一个映射关系
我知道这个虚拟节点A#1结尾的就是Node A
形成一个更多的虚拟节点
那么这样的话让我们产生这种
数据倾斜的问题就变得更少
同样的我新增一个节点的时候
你把它变成255个虚拟节点
那么它会插在任意的一个位置
那么对全局的任何一个当前剩下的节点
大家都会贡献一些数据出来给新加入的节点
那么这个就是一致性哈希优势所所在的
所以我其实当时好早以前用一致性哈希的时候
自己还实现过
其实也非常简单
加入虚拟节点以后
它还是一个二分查找的一个过程
搞出很多虚拟节点以后把它拍成一个数组
然后每个人有一个数字
然后二分查找看命中哪个节点
然后根据虚拟节点映射的原节点
然后就知道你应该存储在什么位置了
所以一致性还是有这么一个优势和好处
解决了数据倾斜的一个问题
那么一致性哈希在业务的一些场景里面也会使用一致性哈希
我希望某一个key开头的一些用户数据
或者是某一个红包id的这种数据命中到某一个节点
去做一些这种什么请求优化之类的
但是我就举一个场景
我当时看到一个微信红包
不知道谁发的一篇文章
介绍微信红包的一个架构
我们可以想象一下
在一个群里面一个上千人的群里面发一个红包
大家是不是疯了似的去点红包啊
假设我们是在过年的时候
其实有大量的这种红包的这种生成的逻辑
那么我们想象一下
如果一个红包发到一个万人群或者更多人群
大家都去抢
这个请求它会分散在很多红包服务的微服务的节点上
那么最终这些红包算出来
你要去把这个算出来扣多少钱
还剩多少钱是不是你得依赖存储
依赖红包的对象的存储
有一个人拿了两块钱减掉从数据库里面扣两块钱
那么这个时候如果有并发的请求进来
一般来说你会用什么
用悲观锁去让数据库用一个行所去锁定这条记录
锁定这个记录然后扣两块钱
下面那个人才能排队进来处理
用悲观锁其他人都在排队
最终这个红包可以被公平地分出去
这样其实你就依赖了底层的数据库
来解决上层的这种业务并发的问题
那么用一致性哈希的好处是什么呢
你是不是可以在网关层用一致性哈希
让某一个红包id命中到你的微服务的某一个节点的分片
这个分片就处理这个红包id让
这一批用户的请求是不是可以在内存里面啪啪啪计算一波
然后最终把这些针对数据库的冲击
这种行锁的冲击
悲观锁的冲击
在内存里面算好以后当然也是定时定量
然后一次更新我们的红包数据库
这种是不是所冲突的问题就在我们的业务应用层就解决了
但是我看到的是微信红包的一个
解法是用一致性中的某一个节点
然后用memcache做锁
我猜测当时微信的架构它
可能是C++的一个单线程的线程处理处理请求的这么一个模型
那么它其实是一个机器上有好多进程
那么进程之间加锁不好处理
那就要利用本地的一个memcache
或者本地的一个状态服务来进进行加锁处理
当然我们如果用Go的话
因为你可以用多线程的一个模型
那么你可以在内存里面
你可以在我们的Go的代码里面用mutex
我们sync的Mutex互斥锁来解决这个问题
相当于合并一波这种红包的这种计算逻辑
然后一次更新数据库其实它的收益就会非常大
大大减少了存储层面的一个单行的锁争用
但是这个做法对于红包场景
因为群人数有限
没有说什么几百万的群没见过
但是如果你像你哪天做的一个业务场景是
像春晚全中国人民来抢红包这种
某一个红包id
这种当然还有很多做法
我只是假设有这么一个场景
十几亿人的人来抢这张票或者抢红包
更建议的做法
你这一个红包一致性还是命中到这个节点
是不是就在应用的server产生了一个什么热点
如果这个红包真的非常热
是个超大的万人几十万的群都可以抢
并且那个人发了很有钱
发了100万的红包
不得了对吧
锁争用超级厉害
不说锁争用超级厉害
应该是节点的热点非常厉害
那么我这个地方就介绍一个一致性哈希的升级版的算法
叫有界负载一致性哈希
那么这个算法在原来的一致性哈希算法上做了一些小小的优化
那么它对ABCD这些节点它会有一个负载因子 叫θ
每个节点不能超过1+θ倍的负载
比方说4 6 3这些节点
一个时间窗口内
它命中的B节点
那么1和5命中C节点
那么假设我这个时候再插一个节点
它插到这个位置
那么它认为B当前存在的压力过大
它就会继续顺时针往下漂
也就说这个节点它会跳过
它会放到C节点
那么这样利用有界负载一致性哈希
它其实是带了一定的负载的信息来做一个决策
然后当它扛不住
就是它认为超过它的总容量的一个承载能力的时候
它这个数据会往下飘
飘到下一个节点
这样的话同样的红包场景
我们其实就可以把它打散到好几个节点
那么最终虽然你看上去对数据库的冲击变大了
但是因为你的收敛比是100万到你的节点数量
你的节点可能是1000个
也就是说它的是100万到1000
1000再到一个行锁
那么比全部打到一个节点去做处理是好的多的 对不对
就说有界一致性其实在很多负载均衡的场景里面
尤其是对这种需要按某一个key
sharding到某一个节点的这种场景
它是一种优化
它是尽可能让它sharding到某一个节点
当它过载的情况下
它会飘到下一个节点
所以这个算法在我们当时
解决我们大数据传输体系
有一个我们因为要上报数据
某一个日志id应该写到某一个kafka的partition
如果你有一些日志流
某些logid日志流特别大
你如果还按这个算法去漂到某一个partition的话
这个partition就会产生热点
我们当时就是用一致性哈希来解决这个问题的
那么为什么要有一致性哈希而不是随机呢
因为我们期望同一个日子流的数据
尽可能创行到某一个partition
那么去往底层写的时候
可以做更好的一些数据聚合
这样的话我对底层的会更友好一些
所以我们当时是做了一个这样的优化
那么这个其实就是有界负载一致性哈希的一个好处
其实一致性哈希还有很多演进版本
在我的reference里面
还有谷歌在网络上使用的一个一致性哈希的算法
大家可以去后面参考一下
其实我们也是举了一些案例
举了一些案例我们怎么用的
那么还有一些求余的所谓的求余的一个方式
求余的方式因为我们之前讲了
原始数据的特征分布不均匀
导致大量的数据集中在一个物理节点
这种哈希求余的一个做法
因为就是说加入或者删除一个节点的大量的数据需要移动
其实也是那个不太好的
那么哈希的这种求余的
对于我们之前
包括历史的一个场景其实是可接受的
因为用户的id它相对是比较风散的
而且我不太会去改
余1000余100我不太会去改
所以在那个场景比较好用
那么其实有一种更高级的玩法
其实非常常见
就是在redis cluster里面
它叫slot 叫slot sharding
我们其实也叫slot sharding
是怎么玩的
是一开始就预分配的16384个槽
我们叫slot
一开始就分配了这么多个槽位
然后我们先看它是把每个key按照CRC16的算法
把key转化成一个int
然后这个时候还是用哈希求余
由于之前讲的哈希求余直接命中物理节点是有缺陷的
那么它的做法是哈希求余之后先命中的一个槽位
那么你这个槽位是有个范围的
比方说你命中到第0个槽位
那么0个槽位它到底去哪一个物理节点上把这个key放进去呢
所以redis cluster的核心
就是每一个节点会负责一批槽位
比方说node1它负责的槽位是0~3276
3276 槽位归归托管
我们可以想象一下
先用key算CRC16哈希求余 余16384
看命中中哪个槽
命中哪个槽在反查
槽归托管 node-1托管
那么最终redis就把这个数据放在node-1上
请问这样的方式跟直接用哈希求余命中物理节点有什么区别
核心区别就在于迁移
假设我这个时候新加一个node6
我要做数据迁移的时候
我其实很简单
我只需要让node12345
每人贡献一小部分槽位出来给到6
是不是就均匀了
那么迁数据的时候就能
告诉node-1 请你把槽位0到多少多少给node-6
那么它的迁移的粒度是按照槽位的里面托管的这些key来迁移的
相比来说你去扫描整个redis
把每一个key全部算一遍
看是哪个槽的去迁
看是应该命中哪个节点去迁复杂的多
你可以想象下你node-1如果是按照key命中的某一个节点
你这是要把里面的数据找出来搬迁
是不是很难搬迁
你要把所有的key便利一遍
你才知道哪些节点是去node-6
我们现在做法
只要在的node-1里面找出slot 0
这些key把它迁出来
那么你这个时候就好多办法了
你可以在redis内部内存里面
维护一个slot的当下有哪些key
你只要遍历这一个一小部分的数组就可以把它们迁走了
所以核心就是方便迁移
其实非常有意思 我们叫slot sharding
其实很多kv的存储也会按类似的思路去设计
如果你是无序的这种kv的一个storage
很常见的就是用slot sharding来做数据分片
它是非常常见的一种sharding的一个玩法
所以这些其实大概就是memcache redis相关的一些原理
还有我们知道的内存池
还有一些哈希算法的一些玩法
我建议大家还是后面还要去更深入的去阅读
我们再来看看缓存的一个模式
最常见的第一个问题就是数据一致性
我经常面试的时候会问
我们假设现在要更新MySQL
更新完MySQL
我们肯定是要把数据回写到redis
回写到cache里面
请问数据库操作成功了
虽然代码会继续执行
你的redis操作一定成功吗
不一定
这个时候就会出现不一致
因为你数据库操作成功了
你下一步操作redis超时怎么办
你是无限重试吗
你重试几次
重试三次还是重试无限次
你要保证一致
你是不是要无限重试
一个接口不可能无限重视
哪怕你无限重试
我再举个场景
你假设现在操作数据库成功了
你的应用在操作redis的时候突然挂掉了
你在启动的时候redis的数据是不是就缓存就脏掉了
就是过去的一个版本
所以它会出现缓存和数据库出现不一致
我们怎么做
我们其实也是一种最终一致性的做法
我们先是同步操作数据库
就是我现在要做一个操作同步操作数据库
同时也同步操作cache
但是我会兜底
起一个后台的job
那么这个做我会订阅MySQL的binlog的数据
那么我会再回放一下binlog
重新对缓存做一个补偿
那么在job里面我其实就是要看你的业务场景
是无限重试还是重试三次记个日志
不管怎么说
这个做法这三步操作既保证了时效性
因为你立马操作数据库
你的缓存其实也立马可见
同时保证最终一致性
你的缓存有可能这一次失败了
但是我通过job的回放
能够让cache操作
最终能够成功
我们解决数据库缓存的不一致问题
基本上是靠这个模型做到的
是不是就到此为止了 并不是
我们来看一下有一个什么样的场景
还会导致数据不一致
先看在标准的一个cache aside模型中
我们的读缓存miss的回填操作和修改数据库同步更新缓存
包括消息队列
异步补偿缓存
无法满足Happens Before
Happens Before是Go的内存模型也用了这个词
说白了是无法保证谁先谁后的
我们来看一个case
我有一个service
第一步会去取cache
这个时候miss了
第二步查数据库
第三步塞缓存
假设我现在执行到第二步
查了一个数据
版本等于v1
我准备塞缓存的时候
我的服务的另外一个节点
甚至是同一个节点
接受了一个update的操作
对同一个数据进行一个写操作
第一步更新数据库
第二步删除缓存
假设这个时候我的第二步在这边我第二步做完了
取出来的数据是那么这个时候你更新了数据库
准备把key删掉
那么这个时候因为你删缓存是cache miss了 你删啥
直接删空了啥也没有
第二步执行成功了
这个时候v1数据重新塞到了这个cache里面
它的版本是多少是v1
我这个数据库更新的时候其实已经更新成了v2
是不是就产生了数据缓存不一致的情况
对吧
它就产生了一个数据缓存不一致的情况
即便我这个时候有一个后台的job在做操作
因为这一步update操作完毕以后
我的binlog感订阅到这个消息
我可能也会做一个删除操作
那么删除操作和回塞回填缓存操作谁先谁后
是也是无法保证的
大家就可以理解吧
就是说我的binlog在异步消费它
有可能我binlog解析速度非常快
立马去删除这个缓存
甚至它回填可能不一定删除
可能是用最新的值 v2去填
填进去一个v2
当时这个节点
因为取出数据是v1
假设它产生了一个GC
卡了几秒几毫秒
卡了个几百毫秒
那么这个时候把给覆盖掉了
尴不尴尬直接覆盖掉了
那么又变成了v1
你可能一瞬间看到的是v2
那么又把它覆盖掉v一
所以job的消费 update的写操作和读操作的回填
它们三者之间无法满足谁先谁后
所以就会产生数据不一致
我们再来看一下我们怎么去优化的
我们再重新看一下
还有一个场景
在同一个服务里面
黑线是读 红线是写
第一步读miss了
去读DB第二步
第三步写变成v2 这是v1
那么第4步set一个cache
第4步是写set的
那么因为我们刚刚使用的DELETE对吧
我们现在看看写 set个cache set的是v2
这个地方很尴尬
set黑线的读又set回去把它覆盖掉了
因为第4步和第5步覆盖缓存
导致第4步写的新的v2
会被第5步写的给覆盖掉
因为它们两者第4步和第5步是
无法保证谁先谁后的
怎么解决呢
我们看PPT其实就一小个地方改动了
我们再看一下这个改动是什么
第一步读cache miss
第二步读数据库读出来
v1这个时候是v1
读出来的数据是v1
第三部覆盖掉变成v2
第四步塞一个v2进去
第五步注意这个地方
第四步是set cache是覆盖
第5步是add cache
add cache对于redis的操作是什么
叫setnx
setnx指令大家可以查一下
setnx表示的是如果这个key存在
那么add cache它会跳过
也就是说实际上我在第4步和第5步他们之间
加入了一个优先级
认为写操作是优先级更高
它要覆盖掉里面的缓存
对于cache miss回填的写操作它优先级更低
如果已经存在的就跳过
所以用redis setnx可以跳过
同理 我们对于job异步后台的这些操作
也可以用set cache去强行覆盖
那么对于回填操作一定要用setnx
已经存在的就跳掉
其实就是产生一个优先级
那么就不会导致这个数据不一致
但是的的确确我们这个地方还会出现一个小缺陷
把这个问题抛给大家可以想想怎么解决
如果我这个时候对同一个key有连续的一系列的update操作
那么假设做了5次操作
我最终update操作把这个数据变成了
用set cache把它变成了v5
但是我job在消费binlog的时候
获取到v1 v2v 3v ... 5条记录
那么它会一次的v1 v2 v3 v4 v5
重复做5次
那么重复做5次
用户刷的时候
有可能他会一瞬间看到一个过去值再一刷又正常了
所以它会有中间的临时的一个状态不一致
因为它是最终一致的
那么这个问题可以抛给大家想一想
有没有比较好的一个办法来解决掉
这个可以想一下
如果你是用set cache在写操作是用覆盖覆盖这个行为来做的时候
有没有办法可以解决
可以想一想
这个是我们说的数据不一致的解决的场景
我们再看一下多级缓存
我们当时还遇到过case
我们当时账号服务
大家知道账号服务它实际上涉及的内容非常多
有会员就是有用户的基本信息 有VIP
有什么用户装备啪啦啪啦一大堆这种账号服务
那么在这种拆分下
我们一开始还拆的很开心 各种拆
拆完之后麻烦来了
我现在要给别的业务部门去调一个账号接口的时候
他得调你七八个微服务
把这个数据给拼凑出来
还得考虑我们之前大家知道我们讲过的errgroup
它的并行请求这么多底层的那些账号服务
就数据是很碎片化的
导致它读放大非常厉害
导致它的最终性能变得特别差
出于这个目的
当时我们其实也没有把服务先整合
我们第一阶段其实是把账号服务
让别人来对接的时候
我提供四五种不同粒度的一个账号接口
比方说有用户详情
有各种字段都有
用户卡片 简单的一些
还有用户基础等等我
拆成几个粒度给它来使用
那么这样拆完以后
我做了一个聚合服务
其实有点像BFF
BFF其实做数据组装
但是我们聚合服务聚合模型
从架构理念设计上它聚合模型
聚合模型实际上它是内部的一个微服务
它用来提供各种粗粒度的一个接口
那么它内部用errgroup来做掉
我们当时为了减少rpc的一个扇出请求
我们在account service账号服务做了一个二级缓存
二级缓存其实就避免向下游这些这些底层
这些数据去减少他们的一些扇出
那么在这种多极缓存的场景下
我们要做到数据的一致性要怎么做呢
两个点 清理优先级是有顺序的
我们一般要优先清理掉下游的这些缓存再能清理上游
如果你是先清理account service的它的二级缓存
再清理 member service的缓存的话
它就会出现一种情况
这边刚清理完一个cache miss透到下游
又拉了一个脏数据v1
然后你再去清理member service把它变成v2的时候已经数据脏掉了
所以它必须从底层开始清理 再往上层清
这是第一个
第二个
在这种多级缓存一定要注意
越下层处于越底层的这些微服务
那么它的缓存的TTL
它的过期时间一定要大于上游
否则就会出现上游假设他的cache一抖动
各种开始cache miss透到下游
下游因为你的过期时间比上层还要短
它就会透传到数据库
你就很尴尬
因为它一cache透传也会透传到你
你又miss
所以越底层的服务
它的缓存时间一定要大于上游
避免层层透传
这是两个点要注意
从我的视角来看
这种模型其实对架构设计上会变得比较复杂
叫天下大事
分久必合 合久必分
所以适当的微服务合并也是不错的做法
更长期来看用户中心你其实可以考虑有一些服务
可以合并到account service里面去
那么在使用DDD的一些思想
还有我们介绍的一些目录的一些结构的组织方式
虽然account service会变得比较大
但是你用DDD的一些思想
通过一个工程
但是良好的目录结构设计
区分不同的use case
区分不同的biz或者data层其实也可以划得很好看
因为我们知道DDD里面的一个聚合根
一个有界线界上下文
它搬出来就是个微服务
你其实通过DDD的组织结构方式
它也可以划成好多个目录
只不过它是在一个工程里面
那么rpc的请求就变成
一个项目内的一个一个数据处理 数据组装
它就减少一些内网的rpc请求
但是二级缓存也是一个不错的思路
告诉你在处理一致性上要有这些考虑
这个是多级缓存
那么对于热点缓存
以下几个思路
其实我们之前举过很多个类似的场景
第一很高频的非常多读
少量写的这种运营类
某一个banner的展示一个什么东西
某一个位置要配一个什么图片
我们核心都是要用小表广播
小表广播说白了就是各个进程
每一个微服务的进程
自己利用in-process的cache
把这种remote cache提升为local cache
来减少对redis的请求
减少对缓存的一个请求
这是第一个热点缓存
因为你很高频访问
那么你通过你每一个节点
你每一个account service去承担掉流量请求
并且减少网络请求
是非常常见的
其实第二个我们也说的比较多
我们叫主动监控
提前发现热点
自动提升为local cache
我们讲过两个思路
旁路的像直播房间这种高在线
你立马可以通过一个旁路的服务
主动通知你的所有room service你的房间服务
全部把这个房间的信息全部local开起来 很常见
那么还有一种思路呢
其实是通过框架
去自适应的发现某一个key最近被高频访问
自动提升为接口级做一个缓存
其实也是很常见的
我希望我们kratos2.0发布以后
逐渐把这种热点发现的这种kit的基础库的能力
打到我们的加PC或者是HTTP的middleware里面
我觉得这个其实也是很重要的点
叫框架的支持热点发现的一个功能
那么其实刨掉这些问题
还有多cluster的支持
我们之前讲过对于账号稿件这种L0这种级别的服务
我们都是多副本
我们是服务层面区分集群cluster102对吧
我们区分多套集群
每套集群在使用自己的缓存
实际上意味着对于同一个用户账号信息
我实际上冗余了好几份
在不同的redis实例里面
这样的话其实我的整个热点应对能力就高得多
同一个key我有好几个副本
因为我上层是好几个账号集群
那么即便有热点
其实我的抗压能力大得多
所以我们底层的服务越被高度共享的服务
应该就是使用多cluster的多集群的模型
多套独占的cache来解决这个问题
当然我们在key设计上也有一种取巧的办法
我们发现这种热点的时候
我们把 key类似像一致性哈希一样加虚拟节点了
我们把key加上#1 #2 #3 #4 #5
我把它冗余255个副本
我不需要通过搭多套redis实例来解决
我只要把热点的key
把副本数量变多不就好了吗
那么怎么做
可能需要你先识别热点
所以我们当时也在讨论
是不是我们自己的cache proxy overlord里面去识别
识别出最近的某一个key是一个很高频的请求
比方你的key本来是叫key1对吧
我就key一把它加个#号
#1 2 3 4 5 6 7 8 9 10
你来一个请求
我会把它用随机也好或者用个轮询也好
会命中任意一个带尾号#12345一个这样的key
把它打散到整个redis节点里面
其实也是可以的
那么这个是通过中间件的解决方案
一个是基础库提升为local解决方案
一个是通过中间的cache proxy识别热点
自动把它后搓
把它冗余到n个地方去
因为你加一个后戳它一定会miss
从DB里面一捞就会热掉
这时候其实很容易尽快把它扩散出来
就把它请求立马扩散出来
让它冗余好多个副本
甚至说不需要cahche miss
我立马做一个复制行为
把它写好多份
也是可以的
其实中间需要去解决的
我比较倾向弱依赖中间件的话
通过我们的框架自适应去发现热点是
可能也是一种不错的机制
那么对于建立多个cluster
建立多套集群的微服存储
实际上它就冗余了好多份
那么这个时候它是典型的同一个key
每一个微服务的不同的cluster
不同的集群里面都有一个副本
那么这样其实也会间接带来一个一致性的问题
举个例子啊 你在集群1集群2...都有key
那么我在数据清理的时候怎么办
我们这种空间换时间的做法一定会带来一致性的问题
因为它是副本策略
我们当时的做法还是利用我们的job订阅binlog
但是这个时候注意我们是rpc框架支持anycast
什么叫anycast 叫任播
也就是说我这个job收到binlog的请求之后
我会广播给我下游的好几个集群
比方有两个集群group a和group b
我会把rpc group到这个请求
向下扇出两次一个到group A 一个到 group B
那么这两个集群收到请求之后都会去做开始清理
那么分别就清掉自己的缓存
所以它就anycast广播到其中的两个节点
或者叫应该叫广播可能更好
是广播到任何任何两个集群来做cache的一个更新
那么在有一篇论文叫Scaling memcache at facebook
这篇paper其实在我的reference里面也提到过
在Facebook怎么去扩展memcache使用
它里面有一个很有意思的思路
大家知道在cache更新的时候
一般常见的做法其实是delete
因为delete它是不会出现副作用的
那么用update它会有副作用 为什么
假设我有两个并行的update
一个是v3一个是v4
你是无法保证最终这两个请求塞缓存的时候
一定是v3先执行 v4后执行
就会出现开始不一致
我们一般更新操作都是delete cache
因为delete删两次是没副作用的
那么delete cache它会有一个毛病
假设是一个超热门的key
那么用户做了变更
做了一个变更操作
那么这个时候很尴尬
超热点的key做了一个变更delete掉
一旦delete
刚刚说的它是个超热的
用户一刷 我* 全部涌到我们的数据库里面
数据打爆了 因为它是超key
那么删除它是有一定风险的
那么当时Facebook它的解法也非常有意思啊
它把key删除的时候
它并不会立马把它给干掉
它是在memcache或者redis的源码里面做一些小改进
delete一个key的时候
它会把它临时挪到一个区域
那么你这个时候一短时间内去请求这个key的时候
他可以从临时区域
可能临时区域TTL可能就是10秒钟
还可以把它访问得到
那么你这delete不会立马透传打到我们的底层吗
但是这样有个好处
短时间内它的的确确过期的
那么它在返回的时候
cache查找返回之后他会
告诉你是个过期数据
你要不要用
你如果业务接受的话
你就用业务不接受
可能你就不能忍受的话
你得做其他的一些逻辑处理
我觉得这个思路非常好
因为你第一个它不会立马删
它是过一会再删
我就可以做个逻辑
10秒内有一些读请求读到我这个key之后
怎么办
会返回老的数据给我
我还可以读出来
但是它会打上一个stale标签
你是不是立马触发一个回写回填操作
sets一个真实的值
然后不就解决了吗
因为它告诉你过期了
你肯定会触发一个回填操作
这样的话既不会导致cache miss到DB
同时又告诉你数据过期了
你赶紧去数据库查一下原始数据把它填回去
我觉得这个思路非常好
其实我觉得非常好
可能要改一下memcache的或者redis的源码来支持这种场景
那么还有一个我们缓存的应对就是缓存的一个穿透
我们其实之前也介绍过很多穿透的一个解法
我们第一个叫single flight 单飞
单飞我其实讲过很多次
我们对某一个关键字对某一个key一般来说
进行一致性哈希
某一个维度的key命中的某一个节点
那么在节点内
我们一般用护持锁保证归并回源
因为single flight其实解决就是归并回源
你某一个红包命中的某一个节点
某一个节点用single flight去归并回源
不就是一种解法
但是你即便不用一致性哈希
你命中到你的任何一个微服务的节点
用single flight扇出比收益其实都非常大
假设有1万个请求 你有50个节点
那么你的收益比就是1万到50
也就是说最终只有50个并发去查下游
那么你的归并是非常好的一个做法
那么single flight前面
我们也简单看过它的一个源码
我就不展开说了
那么第二个其实也是大家都知道的
经常有有同学在群里面问我
老师我有个场景需要分布式锁
注意我是通通不建议用任何分布式锁来做这种场景的
很容易在应用层代码出现bug
而且我们用memcache redis临时做这种cache
做个lock其实也是非常不安全的
很难说保证真真正正只有一个人去持有锁去做这个操作
其实很很危险
所以我是不建议的
第三个是我最建议的
我们一般来说cache miss
我们产生了一个这样的行为
我会先建议大家说
用single flight去查DB
归并回源查DB
同时用single flight的思路产生一个指令
告诉我的消息队列
有一个人产生cache miss了
那么交由我的一步的job
去做cache的回填
那么这样的话你有一系列的key
即便你single flight之后也有一系列的同一个key
在没有回填回缓存完毕前
有一波key留到kfaka的partition
那么你在消费的时候只要做一下key是否存在
你也可以跳过一些冗余的数据
其实用队列架构上会变得复杂
但是你不依赖任何很高级的一些中间件
就能做到一个这样的一个效果
那么在评论也好 在播放历史
其实我们都提到了用这个思路
用归并回源放cache miss的消息
同时在消息队列消费的时候要做一个预判定
key是否已经存在
不需要重新构建缓存
那么这样的话对于穿透缓存就有非常好的一个优势
另外再回源查DB的时候
先用single flight合并掉
所以它会好得多
那么我们再看一下Facebook的一个做法叫租约
通过租约机制也可以很好地避免两个问题
第一这个租约是个64bit的一个token
它是一个int64
然后它会与客户端的一个请求的key绑定
简单来说你访问一个key不存在
访问这个key不存在的时候
比方访问redis或者memcache
他们可能改了源码
返回的时候
第一个产生cache miss这个人
它会颁发一个token给你
那么你这个时候拿到这个token
你去查数据库
因为其他人这个时候再来miss
它不会给你返回这个token
那么拿到token的人
你是不是查数据库
查完之后再把这个token带上来
这时候cache redis或者memcache判定token合法
那么你这个数据可以写进去
假设一段时间内发现没有任何人来回写缓存
那么10秒以后它会重新再针对这个
可以重新颁发一个颁发租约
颁发token实际上就是什么
要做归并回源
只交给一个人去取
拿到token的人可以去取数据库的内容
其他的人干嘛
其他人是不是在那等待
等待过一段时间他去看一下memcache或者redis里面的数据
key有没有构建好
返回就好了
所以也是一种思路
其实核心就是只让
穿透缓存的核心解法其实都是一样的
只让一个人去取DB
不让DB被穿透打挂
或者是我们这里的思路
其实是归并回源一个人查DB
同时归并回源到kafka
只交给一个人去构建cache
这两个事情都要做
因为构建cache的成本很高
很容易吃大量的内存
是特别容易把我们的业务进程om掉了
所以我这个地方还做了一个优化
就是说希望大家去走消息队列去构建异步构建完成
那么它其实对我们应用程序的应用进程
它的内存压力其实会小得多
那么这个是穿透缓存的一个解法
那么这些模式我基本上就啊介绍到这里了
所以你在工作中在面试的时候遇到什么
热点缓存怎么解决
穿透缓存怎么解决
缓存一致性怎么解决
你其实已经可以对答如流了
但是还有一些高级的玩法
像CIDT这个地方我就不展开讲了
你可以自己搜一下CIDT
怎么去解决缓存一致性的问题也是蛮有意思的一个点
我们第三部分再来介绍一下缓存的一些技巧
第一个叫什么
如果我们在一个内网的环境里面
假设我们内网的环境里面网络很密集 包很多
那么有可能会发生一个这样的现象
叫Incast Congestion这么一个问题
你可以理解为就是我们的 etwork里面
我们网络里面有很多个交换机路由器什么的
一旦一次性发了一堆包
这些包同时到达你的交换机或者路由器
这些交换机路由器可能很忙忙不过来
那么这个时候你的cache的很多请求就容易超时
其实这个问题就是说不要让大量的包在同一个时间全部发送出去
那么我们一般会在客户端限制每次发送出去的包的数量
那这个是什么一个问题
我们假设产生cache miss
你是批量获取用户接口
你批量获取用户接口
那么你可能会遇到一大波的cache miss
好多key可能批量用户里面有大部分key都没命中
那么如果第一个你很高频
你如果是一个个去发指令
首先它的小包非常多
容易给我们的网络处理带来瓶颈
所以我们第一个思路是合并
合并这些包
但是如果你合并的包啊
你合并的太大了
我的网络我的交换
对于这些很多大包同时发送过来
大家都知道为什么会同时很多大包发送过来吗
一般来说网络产生抖动
内网产生一些延迟
很有可能这一波一瞬间超时
大量的这些节点都会cache miss
cache查不到 或者超时
查完DB之后要回填对吧
那么一大波回填
导致的这一大包的这些堆积的这些东西全部塞过来
那就会自然而然导致我们的我们的交换压力变得大
可能把它搞崩了之类的
我们这个时候cache miss的时候
pipeline redis打包的数据
它是根据上一个请求
延迟有多少
自动把pipeline的数据增加或者减少
如果latency非常高
pipeline的size变小
latency很顺利
我就一批回源
打包回源可以多一点
其实是很高级的一个玩法
也是在paper里面提到的 就是Facebook的paper
所以一般来说我们业务代码很少会这样写
但是我们的proxy
如果你有一天要自己做一个cache proxy
可以把这种策略应用上
它告诉你聚合的一个粒度的大小
我觉得这个其实是很高级的一个优化
我觉得蛮有意思的
所以我单独提一下
那么剩下还有一些什么小技巧呢
第一 我见过好多同学把key设置的非常长
得得得下划线很长
ABC下划线
尽可能key设置的短一点
减少资源占用
第二 我们向redis的value
你有一些类型可能是int
那么这int你就不要用string 不要用字符串
因为你在redis里面塞int的时候
对于小于n的这些value
redis内部它有个叫shared objects的缓存
减少创建这么多个redis内部的一个对象
也就是说如果你等于一
它复用你有1000个key都等于1
这一切都可以都会复用一个对象
相当于在redis的全局变量里面有
一个这样的1~1万甚至1~20万
其实可以自己魔改代码把它改得非常大
假如你内部的redis成的很多是计数的信息
你可以把这个数字改得非常大
那么它尽量因为
你什么计数器100 300 500 600
其实就不会创建那么多同样的key的对象
它其实可以大家都复用一个
其实这个是很有意思的一个思路
第二叫拆分key
拆分key指的是我们在redis里面用hashs
同一个hashs的key
因为都会落到同一个redis的节点
有一些情况下hashs过大会导致节点之间的内存不均匀
那么有时候就要考虑你hashs太大的情况下
把它拆成好多个小的hashmap
使得内存节点均匀
同时避免热点
所以我们在redis key的设计上
一定要考虑它的场景有没有可能会出现内存的热点
或者是请求的热点
那么你就要把考虑把它打散拆成好多个hashs key
最常见做法我们也之前也讲过
region sharding把它求余 分成好多块
第三个 空缓存保护
有人来刷接口的时候
传一些不存在的key
查cache查不到 透传查DB查不到
也就是每一次它构造的这些请求来透传到你底层的时候
都会导致你出现一个什么现象
都会导致你miss到MySQL的存储
也就是说他是恶意构造的一些请求来透传到你的DB
对你的DB产生一些压力
所以对于这样的一个做法
我建议是这样的
首先我们这种id类型的查找
我们一般会把id向Uber用户一样
把这个id变成那种很魔性的一些字符串
那么这个算法其实只有你内部知道
首先你通过一种算法让它不可枚举
非常有意思
让你key无法被构建出来无法可枚举
第二 有了不可被枚举的key以后
它构建不出来的
那么大部分你就挡住了
第二假设它真找到了一些合法的这种key
但是他也会导致你的透传
他不小心碰到了一个key
然后他用这个key死命的打你
每次都会透到DB
我们一般来说对于查不到的一个请求
我们立马会做一个空缓存的保护
可能就是cache三秒钟
那么如果他针对这一个key 单个key来打你
你其实空缓存保护你就不会次次都被透传到DB
但是如果它分布是构建了好多个来打你的话
还是有些风险
所以我们空缓存保护是有一定作用的
没法彻底解决问题
因为这种7层的CC攻击来搞你的话
其实是很难受的
还有就是说我们当时这种读取失败
我们一般读取失败是分为两种
读cache miss是数据找不到
读失败是读超时
那么读超时一般我们会回查DB
那么回查DB以后要回写缓存
我们对于读失败的这些场景
我们通常这一次的读超时我们不会回写
参考我们前面讲的这种场景
网络交换机可能很忙才会导致你的读超时
那么你这时候回去查数据库再去再去回写
大概率回写也会失败
所以我们尽可能减少对cache的压力
我们在client层面
在这种cache miss的时候做了一些保护
另外就是我们塞入的数据
应该要考虑compact或者是压缩
就是protobuf也好gzip也好等等压缩或者是compact的
用protobuf序列化也好
尽可能减少size
最后一个
刚刚说的这种cache miss的代码写起来啰里啰嗦
我们知道Java的话可以用注解
我们是通过go generate去生成一些cache miss代码
就是说查这个东西查不到
然后cache miss去查DB
DB查完之后回填
然后还有说刚刚说的
读失败后我们这种写缓存策略就放弃掉
等等这一系列的胶水代码
我们都是通过generate去自动生成的
避免我们手写好的代码
因为大家知道Java是用注解
那Go注解不是那么好用的情况下
我们基本上是用代码生成的方式来做掉
那么这些是一些小技巧
那么还有一些小技巧是memcache的
memcache其实支持flag
flac的玩法很多
我前面不是提两个问题
你可以想象flag可以用来干嘛
另外说无论是memcache还是redis
我们尽量都是批量读取 批量写
那么memcache支持gets
我们知道redis 支持mget mset等等
尽可能批量打包
那么批量打包的一次打包多少呢
还是参考我们前面讲的这种
看上一个请求延迟多少自动减少size
这个就在proxy做会比较好
所以你研发中间件的时候就考虑这个思路
这个我就简单提一提
最后redis的一些小技巧
第一个要提的是增量更新的一致性
我们当时以前在评论业务的一个场景里面
用户以前的评论在redis里面有100个key
这个时候它新增了一个评论进来
我们调一个ZADD
我们ZADD不能直接掉
万一key不存在了
你ZADD的进去不就变成一条记录
数据就不一致了
所以我们当时同学怎么写代码
先调一下exist
看一下key是否存在
那么如果存在它再去调ZADD
结果发现这个功能上了以后怎么还是有问题
那么这个时候我们可以想象一下
exist判断它是否存在返回了
然后再去掉自己add的时候
很尴尬 再去掉ZADD的时候
就那么一瞬间key不生效了
ZADD进去变成一条记录
所以我们以前刚开始上线的时候发现评论一开始100条
一刷怎么变成只有一条了
其实就是这样的一个增量的更新导致的不一致
怎么着 我们内部的目前解法比较简单
把exists的指令我们换成了EXPIRE过期
用EXPIRE过期指令去延期key的生命周期
你比方说我先要一个EXPIRE把它延期续期
假如我的key的TTL是8小时
我先让它续期八小时
如果成功表示这个key一定存在
那么这一段时间它也是一定存在的
那么再去调ZADD的它大概率是不会被踢掉的
就是我先调一个EXPIRE续期
然后再ZADD
如果EXPIRE等于0
说明这个key不存在
我再去触发cache miss的一个行为
所以叫增量更新数据不一致
第二 BITSET
这种用BITSET这种方式去存储每日登录的用户
一般在某一个bit位去设置一表示登录成功
首次登录成功
那么我们一定还是一样的要考虑BITSET
bitmap这种方式导致的热点
我们也讲了用region sharding把它分成好的块
第三个 List的结构
List结构我们最常用的就是把它当成一个堆栈
我们当时做抽奖业务的时候
出奖业务其实也是秒杀
就一个活动里面大家弹幕红包
弹幕里面突然飘个红包
同时在线的人都可以去抢
这种场景其实并发量很高
但是抽奖大家注意做这种高秒杀高并发的这种场景
第一个就是考虑你的漏斗效应
有多少人真正能够接触到中奖
所以你先过中奖概率先踢掉一大部分的人
他完全不会去操作cache或者DB
先用中奖概率筛掉一部分人
真正中奖的那些人
他又会去争用一个奖池
所以我们当时我们当时是按照我们的一些礼品的分配的策略
把我们的奖池按照这个一定的策略去分配
就是做成一个奖池包
奖池包你如果用List存储
中奖的时候不就pop一个出来
随机排 shuffer一下
把奖池shuffer洗洗牌
放到redis里面提前预购构建好
然后一中奖就pop一个出来
拿走一个礼品
然后再操作数据库
我们当时是这样做的
但是这样发现List其实他一样的
大家都来秒杀抢一个奖池的时候其实是有瓶颈
所以我们一开始是把它拆
把奖池包拆成很多个List
那么随机取一个
这样的话其实也是一种打散
key的设计核心就是考虑打散和热点的问题
我们再看Sortedset
Sortedset基本上用于翻页排序
这种有序的集合
那么我就提一个点
Sortedset其实就是要杜绝掉zrange和zrevrange
0~-1这种返回超大集合因为
你取的数据量越多
存储现成返回的时间越久
那么你的redis的吞吐下降的就越厉害
一定要注意
不要去大量数据
同样的hashs也是一样的
就是避免返回hgetall
剩下的话像String这种
你就是要搞清楚我们之前讲开始cache存储一致性的时候
SETNX和SETEX一个覆盖 一个是优先级
存在的就跳过
这个其实就简单提一下
剩下的Sets就是类似hashs
但是它没有value
有点像一个set
hashmap是带value的 set就是只有key
其他就是说避免超大value
这些我还是强调多次
一定要避免超大value
还有我们redis也是一样
尽可能去pipeline打包一些指令批量操作
不要for循环批量去打redis
因为QPS打得很高其实代价是很大的
OK 这些其实就是我们今天缓存相关的一些知识都在里面了
Reference里面其实也是提到了很多的
一些点 大家也可以去看一看非常有意思
我提到的一些算法其实也在里面提到了
我们接下来就是要去进入到分布式事务了