一般存储贴子,通常都是在MySQL中进行存储。大概有几个字段:postId, userId, postTime, content。
其中:
- userId记录这个贴子是谁发的
- postTime记录发布的时间。这里只需要精确到秒即可。
- content记录贴子的内容
我们可以对userId+postTime建立二级索引,使得查看特定用户按照时间排列的所有贴子的操作变得更快。
关于MySQL性能调优我后续会专门写一篇文章
select * from post where postId = ?(查询详情)
select * from post where userId = ? and postTime between ? and ?(根据用户和事件查询)
但是采用上面的查询的话,会有一个严重的问题就是:会产生严重的回表(对二级索引查到的每一条记录都需要聚集索引中重新查询主数据),降低DB的吞吐量。所以可以将userId和postTime信息冗余到postId中,去掉二级索引减少回表。
这里可能有人会问了,那插入效率就大打折扣了!!
对,但是无所谓,你发布贴子,博客的时候,慢一点是无所谓的,这就是业务容忍。
postId可用采用首6位位userId,每一位是0-9/A-Z这36个字符中的某一个,6位可以表示21个不同的用户,6位表示时间戳,后续时间戳(精确到秒)可以标识70年范围内的任意一秒,单个用户每秒发放的帖子不超过两位seq表达的最大值。14位的postId可以适用于本设计系统的规模。其中对于timeCompress的计算,可以设计为:
- 贴子发布的时间减去sns系统初次发布的时间点中间间隔的秒,进行36进制编码
- 这样设计之后,timeCompress的字母序随着时间(粒度为秒)连续递增,可以充分用DB的范围扫描。
所以我们查询某个用户发送的贴子的列表的场景,SQL变成了:
select * from post where postId between postId1 and postId2
或者
select * from post where postId like "xx%"
由于查询的是同一个用户的帖子,所以所有postId的前缀是相同的,如果查询这个用户某个时间范围内的帖子,那么6位timeCompress的前面几位也相同。这么一来就避免了回表的尴尬。
随着贴子数量的增加,单机DB的数据量和吞吐量达到了上限,因此我们需要进行水平拆分。
由于postId的前缀中完全包含了userId的信息。所以postId可以独立作为路由运算的单元。
但是某些热点的user的读取量巨大,他们被路由到相同的DB上,后者也可能存在读取瓶颈。为此,常见的方式为:读写分离。采用1写N读,利用DB自身同步机制做主备复制。每次读取随机选取N个读库中的一个。
基于读写分离的DB解法存在两个问题:
- 采用读写分离之后存在数据延迟问题
- 较早的数据极少访问。而一旦读写分离,意味着每一条记录都需要存储多份。当这些数据刚发布的时候,访问频繁,随着时间的推移,他们就不再被访问了。那么百分之99的数据副本不会被读取到,存储效率低。
因此这里我们引入缓存,缓存的数据过期机制天然避免了陈旧数据对空间的占用。
这里我们如何设计Redis的key-value呢?我们不难发现,同一个用户一天发出的帖子数量是有限的,通常不超过10条,平均3条左右,单个用户一周发的帖子很难超过100KB,极端情况下1MB,远低于Redisvalue大小的上限。所以:
key:userId+时间戳(精确到星期)
value:Redis为hash类型,field为postId,value为帖子内容
expire设置为一个星期,即最多同时存在两个星期的数据(假设每贴平均长度0.1KB,1亿用户每天发3贴预计数量为400GB)
对某个用户一段时间范围的查找变为针对该用户本周时间戳的hscan命令,用户发帖等操作同时同步更新DB和缓存,DB的变更操作记录保证一致性。
但是,有些热用户的follower数量极高,意味着这个热点用户所在Redis服务器的查询频率为1000万每秒。
所以这里我们再进一步引入本地缓存来缓解服务端缓存压力。当一些比较热点的用户查询比较频繁的时候,我们可以直接把热点用户存入本地缓存中。用来缓解服务端的缓存的压力。