我们接着继续讲存储设计
我们先看一下数据库设计
也就是我们这个地方使用的是HBase
我们早期的主力存储当时选型就是HBase
HBase的设计我们首先有一张history的表
那么在数据写入的场景里面啊
我们其实就是PUT mid 一个用户id 然后values
那只需要把这个数据写入到column_family里面的info列簇
这个地方我们看一下
这个rowkey是这样打散的
就是用户用户id然后取个MD5
然后取两位
为什么我们要这样做
是因为大家知道HBase
它也是类似像有序的一个region sharding
我们为了避免说某一个用户的操作很容易密集产生一个热点的region
所以我们把rowkey设计上会把它打散
对于列簇column_family来说
我们看一下
它有一个列叫obj_id和obj_type
举例来说的话
比方说稿件业务
它的业务类型等于1
那么稿件id等于100
合并起来它列名就是100_1
就是一个用户的列就是100_1
那么对于value来说
我们使用的是protobuf序列化
放一个序列化的结构体放里面
我们看下这个history的protobuf里面会有什么
首先有这个业务的唯一id obj_id
obj_type业务类型
还有member_id用户id
还有device就是设备
还有progress就是它的进度条
还有sub_obj_id就是业务的一个子id
然后还有view_at所谓的观看时间
或者说你看到哪来了
然后create_at这个记录的创建时间和修改时间
为什么我们之前讲这些字段其实比较多
我们没有全部列
所以我们当时为什么是查一次Redis去补齐完整的那一条记录
再持久化到HBase里面
其实我们拿的就是这一条信息
这个信息我们可能会比较大
还有很多别的字段
我没有一一都列出来
所以我们对于一个写入操作其实就是操作history这张表
然后操作它列出info
然后把它的column列名
就是稿件id或者说视频id加上业务类型
value就是一个protobuf的二进制的一个数据写进去
然后我们写入
你不能一直追加
因为我们之前讲过我们可能是按照保存90天
就是用户最近三个月的数据
然后同时还有一个要求
可能最近HBase里面我们存储的
是可能最近的topN top1000
所以我们也会做一些斩断
也是在HBase里面会做一些斩断
这样的话它满足一定时间
然后有满足一定的
这两个要求都要满足
那么对于读操作来说
我们列表调个简单的get操作
get用户id
那获取到整个info column簇里面的整个1000条记录
我们为什么整个获取
因为我们讲了
我们cache miss时候有两种场景
一种是这个key
一种是这个key列表完完全全没有
完完全全没有产生cache miss
然后去查HBase
我要把它全部从里面捞出来
捞出来以后我是在代码的进程里面
就代码的内存里面去做排序和翻页的
这个是一个场景
因为我们刚才讲的就是Redis里面
我们翻页产生的cache miss去回查HBase
我们是全捞出来的
然后再把那些展现出来
就根据你的time的那个key去把它筛选出来
应该是从哪开始
所以我们当时是这样设计的
因为我们也不会去回写的
因为它可能命中率实在太
那么我们还有一种场景就是之前讲的
看一个视频的时候
它不是有个播放的一个进度需要同步
那么是不是这个时候如果我查Redis查不到这条记录
我是不是可以点查HBase
同样的我可以调
然后用户id
然后传一个列名
列名的话就是我们的视频的id加业务类型
在茫茫多视频
查看当前视频的阅读进度
cache miss会非常严重
也就是说我任意的一个视频
因为我们的视频非常之多
任意一个视频去查我的Redis
大概率是命中不到的
那么如果全部透传去查HBase
可能HBase的压力是非常大的
因为你每观看一个视频都会产生cache miss
因为我们想你在首页看的基本上是信息流推荐的
那推荐基本上都是最近投稿的
都是你最近可能没看
因为你看过的一般不会推荐出来
那么肯定是推你没看过的
你没看过的查缓存大部分都命中不到
那么都会透传到HBase 那么这样的话
我们每一个点击行为都会产生一个miss的话
压力是非常之大的
因为我们全量用户的产生cache miss
行为透传到HBase的查询量
可能体量就会非常之大
所以对于这种场景
这种点查进度的场景
我们只要上层的Redis cache miss以后
我们是绝对不查
我们就不再回查请求HBase
其实某种意义上我们认为处于一种牺牲
就牺牲掉我们数据的一个一定的精准度
因为我的HBase里面数据可能1000条 top1000吗
然后以及最近三个月的
那么即便是这样
我们认为它的命中率仍然可能是非常低的
所以我们牺牲掉一定的精度来满足这个场景
就是点查进度
因为也可以想象一下你的业务的用户场景
一定是从某一个移动端设备切换到另外一个设备
或者说我当前我当前看这个视频我可能被打断了
切出去了或者一些别的原因
我这个时候再去进历史记录的时候
我去按照视频去点击之后要同步进度的话
你之前看过的
你的缓存一定会命中
所以基于这种考虑
我们就不再回源请求HBase
所以你看系统设计上你一定要去做一些这种取舍
你的架构也好
你的底层的基础设施也好
其实会变得更简单
也不需要那么强
我们通过平民架构就能承载一个非常大体量的一个用户规模
我们再来看一下一个数据写入
缓存的设计上的一个数据写入
缓存我们看一下
每次产生的历史数据需要立马更新Redis
因为我们刚才讲了
我需要保证数据的时效性就实时可见
我们这个地方使用的是Redis sorted set
基于时间排序的一个列表
我们看一下key
key的话其实就是一个用户
那么它的member其实是唯一键
也就是说sorted set的唯一键member
我们这个地方也是用obj_id + obj_type
其实就是业务的某一个内容的唯一id
score它是个double类型的
score其实就是这条记录产生的一个时间
因为我们基本上都是按照time line排序的
按照时间线来排序的
过去时间可能是8个小时
因为你这个地方只有1个member
你没有很多详细的protobuf的里面很多字段对吧
我们刚刚前面表这里面不是好多字段
所以我们其实要做一个二次的一个查找
我们会去查找history_content这张表
那么key其实也是一样的obj_id + obj_type就是一个业务的唯一id
那么value的话
就是用protobuf序列化的json
就是跟我们刚刚前面HBase的序列话应该是一样的
当然了我们可以先看一下场景
第一个场景是历史页面
你的全量的历史
你的全段历史
这个时候我们先查sorted set
甚至可能有翻页
那么翻页是拿到一批业务id以后
我在调一次mget
因为这个地方是key value
是个Redis key value
我只需要通过mget
批量查一下history_content
mget其实因为是批量查找
它只有一次Redis请求
所以说它也是一个batch的思路就解决了
这个就是我们的历史页面
那么另外一个场景
我们刚才讲的点查进度
就是点查某一个视频的点查进度
比方说我点击进入一个视频详情页
这个时候呢我只要直接查找history_content表
我直接查obj_id+obj_type
一看history_content这张表
key value在不在
如果查不到不再回源
HBase因为命中率太低
用法上也会比较简单
通过两这两张表
这是它的缓存设计
除此之外
缓存设计里面还有很典型的某次触发某一个行为加经验
我们刚才讲了
那么在这个里面缓存设计
因为首次你不需要去存一个key value
value可能是个0和1
你没必要去这样存
我们通常会使用bitmap
因为bitmap可以大量节约很多内存
因为它只有一个bit位
你如果是放个key value的话
value可能是个int
那么它的大得多
大概要占8个字节
如果是用bitmap的话
它其实8个用户才占了1个一个字节
所以它是非常划算的
所以我们一般会用bitmap
甚至我有时候可能会直接用不能特
来做一个缓存的加速访问
我不管你是用bitmap
还是用bloom filter来做判定
来做首次的这种判定
在使用缓存设计或者说我们做数据库设计
我们做缓存设计
经常要注意的一个点就是要规避掉热点问题
什么叫热点问题
你的Redis虽然是分布式的有多个节点
那么如果你的设计不合理
很可能会命中到你的分布式缓存
里面其中的一个Redis的节点
如果它的访问频次非常高
那么就容易产生热点
那么这个时候有可能就会成为系统的瓶颈
我们怎么做呢
比方说我现在用bitmap来做
今日首次登录 今日首次观看
我们很容易想到我搞一个Redis搞一个key
然后放bitmap放全量用户不就好了吗
但是有没有想过这样的话
全量用户来判定首次观看首次登录类似这种行为的时候
都会去查到这个key命中到某一个Redis节点
那么这个Redis节点是不是就成为了一个热点
怎么做呢
我们其实可以想法就是构建更多的bitmap
或者是bloom filter来进行打散
那么打散的思路跟我们前面讲的region sharding的思路非常类似
我直接用一个希算法对用户id求一个int64
或者我直接用这个用户id%1000
也就是说从一个bitmap
我余1000就是构建出1000个bitmap
我构建出1000个map
相当于把全量用户打成1000个map
1000个bitmap
然后不同的bitmap
比方说bm_1 bm_2 bm_3
那么它就有1000个这样的key
那么这样的话你就被均分打散到Redis分布式缓存里面的任意一个节点
那么热点就被消除了
所以这个思路也是非常像一个哈希求余
或者说用mid求余的一个思路
这样的话分布到一个更小集合的node
那么就把热点给去除了
但是注意我们仍然会每次都触发这个行为
进行一个前置的判定
也就是说我今天只用一次
但是我每天每个行为进来都要去查一下Redis的bitmap做一下判定
有没有更好的方案
这个是我前面提的问题
在可用性设计里面我就把谜底给揭开
我们再最后看一下整个评论历史里面的可能性设计
有哪一些比较巧妙的一些点
首先我们就来回顾一下wirte back
什么是wirte back
在history-service中
我们因为是实时写入的Redis
所以我只需要重点优化它的Redis缓存架构
使用Redis cluster也好
或者你自己用一致性希去写Redis也好
等等 只要你的Redis的缓存架构能够扛住峰值的流量写入
那么之后在服务器的history服务的内存中
比方说我们在Go里面
你可以用map[int]map[int]struct{}
那么map的key是什么 用户id
那么这个map是为什么
因为我通过用户id找到某一个用户某一个对象的某一个视频
我只要保存这个这样的一个结构体
其实我这地方为什么是用struct
因为我只关注用户对这个视频的最后一条记录
前面刚刚说有覆盖的一个行为
不管怎么样
在我这个map里面其实我只关注这个视频
那么它的原始形式在哪里
是在Redis里面
所以这个对象其实是非常小的
因为它很紧凑
利用这样一个聚合数据和数据
我利用channel
在内部我利用channel去发送到一个地方
然后goroutine的一个消费者把这些
收到的这些小map在聚合成一个大map
然后在sendproc里面
在一个发送process里面
利用timer定时定量的一个判定逻辑发送到下游kafka
你可以理解为是什么
每个接口进来的时候可能利用channel
去把这些消息一步的分发到一个地方
然后那个地方会做一些数据的拼凑
Go routine 拼凑一定程度它就批量发送到下游的kafka
那么这样的话进行一个消息打包
所以它为什么叫wirte back
是因为它是事先写Redis
然后再批量打包
然后再通过history-job获取消息
以后重新构建完整数据也是刚刚我们说的查
Redis那张history_content表
构建完整数据在批量写HBase
那么在wirte back的架构设计里面
它其实有两个风险点
第一如果是history的重启的过程中
它是会有一个预聚合的一些消息
因为你这些消息
这些预聚合的这些message
它没有发送到kafka就会丢失
虽然你写了Redis短暂可见
你明白我意思吧
就是你来一条记录
你会实时写Redis
虽然数据不会丢
但是你在内存里面构建的那一小批次的map对象
重启的过程中丢掉了
那么意味着我的kafka就拿不到这个指令
意味着我这history-job拿不到
有人更新了某一个视频
那么我就无法去触发读操作构建完整记录回写HBase
那么我认为第一个问题还好
为什么叫还好
我们可以想象一下
如果重启的过程中的的确确没有刷到HBase
但是因为你的Redis里面有这个数据
所以短期内用户去查进度是不丢失的
并且刷历史页面
因为刷历史页面相对是很低频的
那么这时候因为优先查缓存
miss再查HBase 也问题不大
因为你透1000很容易被冲掉
或者过去三个月很容易被冲到后面去
很久以前你其实基本不记得你有没有看过这个视频
所以说它有牺牲
但是牺牲比较小
另外我们再回想
尤其是你最近看到一个视频
如果没被回刷成功
因为你最近在看了一个视频
它的上报进度
它其实是定时上报的
每5秒上报
所以有可能你还有机会会追回来
所以我认为大概率是可接受的
相对来说是可接受的
那么这里面还有一个小细节
我们在对于这种视频里面这种时间同步
我们一般的定时来做
也考虑过
如果你报的时间周期太长
比方说15秒才报一次或者30秒报一次
你的回退的进度是太多了
因为你15秒内没有上报过一次的话
它的进度就不就回退了吗
我们的做法是这样的
对于视频的前几分钟我们上报的策略会更激进一点
比方说前30秒
我们可能是用那种跳步的方式
第一次是1秒然后2秒4秒5秒然后到10秒
直到到15秒以后
到5秒以后就是每5秒每5秒
前面会上报的高频一点
那么到后面定时去上报
这样的话我的前面的进度不容易丢失
不然我要等5秒才能看得到
这是第一个
第二个就是我们在视频里面其实播放器的退出销毁
我们是可以主动触发一次的
也不一定非要等定时上报
因为你在APP使用过程中你可能会返回
那么这个时候会主动触发一次上报
这也是一个这是第一个问题
第二个问题
history-job在读取Redis构建数据的时候Redis挂了
整个Redis数据丢失 那也会丢
但我们一般来说我们使用Redis cluster
它都有replication对吧
它有副本
有主副本和Slave有个重复副本
第二个场景我认为也相对可接受
两副本同时挂的概率是比较低的
虽然它的的确确是有一定概率有可能都挂掉
比方你的Redis集群就是整个故障
确实会丢这些数据
因为wirte back的思路一定
是利用高性能的缓存去扛住写入流量
然后再一步回刷的
所以diff的时间段内
就是你的风险所在
就看你能不能接受我们在历史的产品里面
我认为是一种trade-off
我们能接受
因为高收敛比的设计
你是靠时间和buffer来堆出来的
那么它的确是存在着数据丢失的一个风险
对于这种历史场景
非L0的业务或者说数据
我们认为极端情况下可接受
不是常态情况下可以接受
极端情况下可接受
所以这个是我们这个系统的设计的
一个非常核心的write back的一个思路
我们再来看一下聚合
我在写这个PPT的时候我也在想
我们经过BFF的流量
就我们上面historyservice上游是BFF
哪一个写入请求
它无脑透传发送到下游的history-service
大家知道什么意思吧
假设我现在请求上报是100万
意味着我的history-service接受的请求也是100万
BFF接收100万 history-service也是接收100万
它是1:1放大的一个流量
这样的话经过BFF history的流量
我们叫per-request
每一个请求都会发送到history-service
那么我们也很容易想到优化
我们是不是把聚合逻辑上移
我们把history聚合的逻辑上移
来减少发送到下游的history-service本身
自己的这个rpc请求能够减少
在最上层聚合带来的收益肯定是最明显的
所以我们同样的很容易想到按照刚刚
说的mid的这种region sharding的思路来做
但是我们一定搞清楚
在BFF来做这个逻辑
我认为侵入的逻辑性是很强的
所以我们一般BFF是做平台服务
或者说接入服务数据组装服务
那么这种业务性能优化的这种东西
觉得它很容易跟业务产生耦合
所以我理解说这种逻辑不应该上移
而只是数据的请求上移 那怎么做
我们可以单纯纯的简单的按照
在history BFF层来做批量的batch的上报
你接受100万
我无脑的按照任意的维度uid聚合
发送一个batch下来
那么在history再把它展开
再按照uid region sharding的思路来聚合
会比把这个逻辑搬上去好得多
就说前面是按照纯按请求去聚合打包下来
那么下游去做真正的业务拆解
去做mid region sharding的思路去发送kafka
我这样的话就是一个无逻辑的数据聚合
再发送到history-service
那么这样的话可以大大的减少内网的流量 节约资源
这样的话大家不要小看
可以节省好多台机器
因为我们在做系统设计一定要考虑收敛比的问题
那么往上游去做收敛
一定会带来的收益大得多
我这里可以再抛一个案例
我当时加入到数据平台的时候
我们当时就有个系统叫数据上报
因为大家可以想象一下数据上报业务
你的APP在全国祖国大地全国各地都有用户
那么它都要通过一个数据上报上报到我们的一个核心机房
因为你的核心机房
我们大数据一般核心机房就是固定在一个地方
全国各地的链路要连接到核心机房做数据上报
大家可以想象得到
链路远经过的运营商和链路又很长
上报质量不能保障
上报的时效也不能保证
我们以前的做法就是那些边缘散落
在祖国大地的各种地方的APP
它的上报记录是通过一个cdn
我们之前讲过可以通过cdn Web
通过我们的一个边缘的节点
因为它离你近
比方说你在无锡可能接收到上海节点
那么上海现在在连到我们的帮你转发到核心机房
因为边缘节点和你的服务器
因为它是两个服务器
是两个我们叫省际网也好或者骨干网也好
你是两个大机房的一个网络
它相对来说甚至还有专线
它的的网络质量好的多
比你从小用户的网络流量到你的机房肯定是要好得多
所以通过边缘节点来做加速
一开始的架构设计就是通过边缘加速解决质量问题
但是有没有想过哪一个请求通过边缘回到我们的核心机房
我们核心机房肯定有一个接收服务
接收服务就是一个我们叫Gateway
一个data Gateway数据的接收Gateway
你是不是1:1 来一个透传 来一个透传 来一个透传
是不是下游我们当时数据接收网关上百的机子来做这个事情
我们是不是在这个地方思路一样的
我把这些聚合请求往上抛到边缘
那边缘做一些聚合的计算
所以经常听到什么边缘计算
这个不就是一个简单的批量打包压缩
我们当时就是批量打包压缩
既节省带宽又节省请求这些数据再发过来之后
我们当时的机器减少了百分之七八十
我们这种只有10台左右的机器
节省了之前的100台机器
那么边缘的节点
因为边缘的CPU打包和压缩的CPU
其实是非常低的
因为这些CPU散落在边缘节点好多个节点里面
其实完全被打散了
那么带来了这些CPU成本其实非常低的
远比你核心机房的一些热点
你有没有想过它就是个热点在你的核心机房
所以类似的思路啊非常像
这是第一个问题
那我们再来看个问题
我们当时还有一个问题是什么
刚刚说有一个心跳上报
有一个播放进度的心跳上报
心跳上报它也是个独立的服务
因为它的流量实在是非常大
也是经过API Gateway到BFF一路下来
最终上报
我们可以想象一下
你要去上报一个数据
播放器上报一个数据
你肯定得知道它是谁
一个HTTP请求已上报一个数据
你要知道它是谁
你是不是要请求我们内网的鉴权服务
我们当时内网的鉴权服务idntify-service
你要去访问一个idntify-service一个鉴权服务
必然上报的多密集
对我们内网的鉴权服务带来的RPC流量就是1:1的放大
是不是问题非常类似 怎么办
其实也有一个办法可以大大的减少这种per-request的off
我们叫per-request off
per-request off就是来一个请求 鉴权一次
来个请求权一次
很简单
当时我们助教Tony老师当时
在给我们搞那种我们开源的项目叫goim
我们应该是第几周忘记了
也会去讲网络这块的一个我们开源的goim服务
那么这个tcp的服务
它的的协议设计是这样的
你在接入到我的tcp的goim
我们网叫broadcast
我们开源的goim
你接入到goim的接入节点的时候
它有个握手协议
那么握手的协议的时候你就可以产生一个什么
你可以产生一个鉴权行为
那么鉴权行为之后连接建立
那么长连接会话一直保持
大家知道什么意思吧
你后续都是基于这条连接来进行通讯的
那么你的身份信息是在内存里面
是在我们的goim的内存里面缓存住了
我知道你是谁 说白了
因为连接建立之后我就知道你是谁了
那么这样的话是不是首次鉴权后面就不再鉴权了
你可以不断的上报心跳
可以大大节省我们内网的RPC的流量
可以大大节省内网的鉴权服务的流量
其实也是一个非常高的收敛比的做法
那么我们广播服务其实鉴权
除了做这个事情
这种播放记录上行条上报其实还可以做一些订阅通知等等
最终我们其实通过产业链服务
还包括一些指令下发
数据下发其实都可以用这个思路来做
其实流量就大大减少了
我们其实还是要通过
我们需要具备一定的技术视野来搞定不同业务场景的一些这种问题
我觉得其实在这个场景里面很多人就不知道怎么解决的
它其实没有想到其实可以通过一个一次鉴权
然后产生一个长连接
来进行一个上报的方式
所以这样也是一个很好的做法
我们再来回到最后我们今天提到的一个问题
用户首次触发的一个行为
比方我们首次登录首次观看
我们需要发送消息给下游系统
进行触发它的其他的一些奖励的操作
那么如何减少这类一天只用一次的标记位的这种缓存请求
我们很容易想到我是不是做一个内存
我不要用分布式的Redis
我直接做个in-process的local cache
只要高频的用户访问
那么它带来的收益就大
我们很容易想到用LRU维护一个这样的集合在内存里面
但是有没有想过我们用户它的分布是很广的
今天活跃的用户是非常之多的
那么LRU在in-process local cache里面很难被全量覆盖
并且命中率可能会非常的低
的的确确高频用户收益高
但是你的用户的常规请求其实是超级多的
很多用户任何一时间会进出进出
所以不是很好
我用Redis的集中式的缓存搞个bitmap
那不就是回到我们之前的吗
你一天只用一次
我们在架构设计里面有一个点
请大家记住
越源头解决问题
解决架构问题通常来说就越简单
效率就越高
我们可以想象一下我是怎么去设计的
我们在写操作
在一个非常高频的写操作中
把当前的flag 当前的一个标记位 0还是1
你今天是否首次假设观看了把这个flag
返回到API的head或者协议里面
你可以把它0和1改一改
把它返回一个日期
只让我们的APP把这个值保存到本地
那么下次请求的时候再把它带上来
那么如果我的API
我们的API的服务发现你的请求参数里面的值
我就获取以后直接使用
比方说我知道你今天已经发送过的
我就直接使用它
我不再请求缓存
我举个例子 2020年1月1号
你把这个值带上来之后带到我的API服务里面
我发现你这个值2020年1月1号
我再看一下我的系统的当前时间
也是2020年1月1号
我就直接不再请求Redis
如果发现当前时间是2021年1月2号
发现你的标记
你首次观看的标记
时间戳它过时了
那么这个时候会触发一次Redis的访问
那么再去看触发一次Redis访问
那就是相当于做了一个记录
就是今天首次观看
或者说我通过Redis打了一个标记
这个时候再把新的时间戳带回到客户端
那么这样的话利用状态同步广播到任何其他设备
可以大大减少判定缓存
所以实现成本在于就是说在APP会有一些逻辑
那么这个代价的高低就是你要跟客户端同学商议是否划算
其实大家有没有想过像Redis
像微信你登录到一台新设备的时候
它不是要同步很多你的通讯录的信息
它其实这种你的用户的好友信息
它有可能是定时的去打包成一个大包
一个zip包或者是一个batch
那么你在换设备的时候
它其实批量的拖一次就好了
它不会像我们去数据库同全量同步一样
类似的这种用户的这种你的通讯录信息
它其实也是去增量去同步放到各地的
大部分情况下都是查的是你的本地的缓存
它其实是靠版本同步去下发到最新的一个通讯录信息在本地使用
否则你不可能每次你的通讯的信息都会去请求网络
那么这个其实代价是非常高的
我们关于整个历史记录的这套系统基本上讲完了
然后大家也可以利用这种wirte back思路
想想你自己的业务里面有没有适合这种场景的
除了write back等等
你都可以去搜
搜的时候如果你发现其实无论内核硬件也好
有很多这种设计理念
有可能都能帮到我们的业务架构设计里面去
今天就到此结束
感谢大家