在小米有品的工作内容也算是和社交有点关系,会有类似微博的点赞,查看点赞列表的功能。
这个功能看起来简单,其实做起来一点都不容易。
为了避嫌,这里以微博为例,讲一讲自己的思考。
类似的,还有关注列表等。这里就简单思考点赞列表。
功能
微博上,我们可以给一个具体的微博点赞,然后个人中心页面可以查看自己点赞的内容的历史
所以基本功能概括起来如下:
- 给微博点赞/取消点赞
- 查看是否给该微博点过赞
- 查看历史点赞记录
在要应对的数据量比较大情况下,要完全实现上面这三个功能也不容易。尤其是这种很典型的具体冷热属性的数据。
所以会有一些产品妥协策略:
- 时间久远的微博,默认返回未点过赞 //这种产品可能会比较同意
- 时间久远的微博,点赞记录中找不到 //这种一般不会同意的,放弃吧
为什么这么妥协会比较好做呢?下面再详细聊聊
下面看看怎么实现
Redis
这个是最简单的实现方式
其实还有更简单的,就是只有Mysql,但是这种一般都不会使用的,除非自己写写应用。
每个用户的点赞列表都存为一个ZSETKey=weibo:like:${uid}
Value=${weiboId},Score=${Time}
- 点赞时加入到ZSET,取消点赞时从ZSET中删除
- 查询是否点过赞使用zscore
- 历史点赞记录用zrange
注意事项一
没问题吗?
是的,一般来说这么搞就行了,但是其实有个不小的瑕疵。
查询历史点赞记录用zrange。
想象如下的例子:
1 | request: { |
好,我们用zrange(key, page, pageSize)
返回前十条
我看到自己的前十个点赞记录,卧槽太傻比了,全部取消点赞
ok,我们zrem() * 10次,把zset中前10个记录删除了。
再来请求下一页:1
2
3
4request: {
page: 1,
pageSize: 10
}
我们用zrange(key, page, pageSize)
返回前十条
发现问题了吗?
第二次zrange的10条,其实是最原始数据的20-30条。
中间有一页的点赞记录因为我们zrem的原因,加载不出来。
这就是用zset做分页的普遍缺点。
怎么解呢?
有个简单的方法,我们用rangeByScore
方法,其实参数最大值,是上一页的最小的一个Score
。
这样,前端每次的请求其实是带上上一页的最小的那个时间戳1
2
3
4
5request: {
page: x,
pageSize: 10,
lastTime: 103232
}
这样就可以解决了。
注意事项二
但是还有个问题:
我点赞了微博id=23。
然后这条微博被用户删除了。
那我从zset中拉到这个id,组装数据时会发现id=23查找不到。
这个时候其实有两种选择:
- 告诉用户这个点赞内容被删除了,微博就是这么做的
- 返回空
返回空其实又带来一个问题
如果我很不巧,第4页的点赞微博都是一个人的,她清空了微博
那请求和响应就会变成这样:1
2
3
4
5
6
7
8
9request: {
page: 3,
pageSize: 10,
lastTime: 103232
}
response: {
[]
}
后端返回了一个空数据。
如果这么定义的话,前端会以为已经请求空了,就会告诉用户已经没有数据了。
这个时候其实就出BUG了。
那这个怎么解呢?
很容易想到的就是:
response中带上total字段,前端判断后续有没有数据按照total来。
那其实和注意事项一又冲突了。不好。
还有个解法:1
2
3
4response: {
[],
hasNext: true
}
用hasNext
告诉前端有没有后续数据了hasNext
怎么来呢?
我们从zset中range获取的时候,如果拉出来的个数小于pageSize,那么就是false。
如果等于pageSize,那么就是true。
妥协策略
全存Redis,当然会有问题,数据量太大怎么办?
对于妥协策略1,我们定时的扫我们的Key(或者查询时,插入时异步操作),如果发现有些点赞记录太久远,就把Value删除。
这样我们的Redis负担就小点,
但是对不起,这样其实把妥协策略2也做了,是行不通的。
类Redis数据库
但是又不想抛弃Redis,因为Redis实现起来确实简单啊。
那怎么办?
类Redis数据库来救场了。
类Redis说白了就是兼容Redis的指令,但是存储上,不全存内存,会存到磁盘上。
目前市面上比较流行的类Redis数据库有Pika,SSDB这种
具体笔者也没使用过,就不做评价,简单介绍下
小公司可以自己搭建着玩玩,但是大公司可能就没这个场景了,需要懂这个的运维来支持。
Pika
SSDB
Redis + Mysql
这种比较少见其实,但是好歹这两数据库在公司都是标配。
主要是Redis存热数据,Mysql存冷数据。
写的时候双写
查询的时候先查Redis,Redis查不到再去查Mysql
分页查询的时候,查Redis,过期了就去Mysql捞一部分,然后存回Redis,设置个过期时间。
太久的就直接查Mysql,没必要存Redis了。
但是这里得考虑几个问题:
- 这种行为数据,实时写数据库一般不会同意的,可以先写Redis,然后搞个消息队列慢慢写数据库
- 查是否给该文章点赞过,先查Redis,如果空了,再查Mysql。可能会出问题,有点隐患,不过也不用太担心,因为在Mysql中的一般就是冷数据库,问题不大。Redis存的容量大一点。
- 分页查询点赞历史,先查Redis,到底了去查Mysql,这里切换的衔接逻辑得好好想想。问题也不是很大。
看起来很不错是不是,但是这种方案,最大的问题还是Mysql。
你想想这个表里的数据长啥样?
就几个字段:
- id:自增主键
- uid:用户id
- weiboId:微博id
- createTime:点赞时间
- del:是否删除了(这个看公司吧,有的只允许逻辑删除)
这表数据太简单了,如果真到微博那种量级,增长速度会很快很快。
假设用户200w,每个人点赞2篇内容,那么一天增长400w条记录,一年就146000w,14亿。
这谁顶得住。
这种其实硬要解还是有点方法:
- 压缩表:把字段weiboId,改成weiboIds,一行记录多存几个点赞记录。数据行数可以缩小几个量级,但是插入,查询和Redis衔接起来就比较复杂了。同时删除几乎不好做了。
- 分库分表。其实我感觉分库分表意义不大。
妥协策略
来看看这种方案,如果产品妥协了,会不会简单点:
妥协策略1:查是否点过赞,Redis查不到,就默认未点赞,不用去查Mysql了。
妥协策略2:查完Redis,去查Mysql,可以支持。
其实再拓展下,如果产品妥协了策略1,那么写入的时候,只写Redis,然后再在某个时间点,把冷数据同步到Mysql就行。
这样就不用双写数据库了,同时同步的时候可以批量查入。
总结
所以综合来看,功能上,对热点数据的点赞/取消点赞/查询是否点赞比较好
如果你压缩数据行:对冷数据(Mysql中的数据),取消点赞,分页查询点赞记录比较复杂。
如果你不压缩:数据量太大
Redis + Hbase
Redis + Hbase算是比较终极的方案了。
其实笔者对Hbase也不是很了解。
了解了再说吧。