假设与要实现的功能
假设可以使用 MySQL,redis,本地缓存以及MQ。
用户量级千万,新闻数据百万,用户数比新闻数还多。用户的操作包括:
- 关注某个新闻
- 获取某个新闻的关注数量
- 获取 top10 热点新闻
- 查询自己关注的新闻。
可以推测,获取 top10 热点新闻请求会远大于关注某个新闻的请求。这些请求都不能直接压入数据库,数据库受不了。
基于 Zset 的解决方案
首先想到的是 Redis 中的 Zset,所有的新闻id作为key放入同一个zset中,用户关注某个新闻,使用 zincrby 给这个新闻分数 +1。读取 top 10的时候,用zrevrange.
并且,在实际业务上(例如微博热点话题,知乎热点话题,都是每过一段时间才更新的),top10 热点新闻并不是实时更新的,可以接受一点延迟,可以通过客户端实例的本地缓存,将读取到的 top 10 存在本地缓存一段时间,过了这段时间自动失效。
但是这样也会很快遇到性能瓶颈:
1.zset 在很大时可能不满足我们对于性能的要求: Redis 的 Zset 在数量够大的时候底层基于 skiplist:
skiplist 实现简单,插入、删除、查找的复杂度均为O(logN)。zincrby 实际上是一个查找+删除+插入(当然由于score只加了1,所以删除插入只修改相邻节点,这个有优化)
我们的场景是首先插入的新闻分数都是0,之后增长这个分数,在新闻很多,并且并不能确定某些新闻是热点的时候,zincrby 导致的节点变动很频繁。这个通过业务设计可以优化,例如新闻分级,不同级别的新闻初始分数不同
2.放入同一个 zset,对于单实例 redis 性能瓶颈时,扩展不友好。
用户千万量级,更新很频繁,如果都更新同一个 zset,很快会遇到性能瓶颈。读取还好说,可以通过本地缓存,因为展示最热点的top10的实时性要求并没有那么高。这时考虑 redis 集群,redis分片,但是如果放在同一个 zset,无法分摊压力。
换一种思路(按照评论区的各位兄弟建议,修改了,之前的太意识流了,不好意思)
数据库中存储每个用户关注的新闻,也就是用户关注新闻表,表结构包括:
用户id 新闻id
还存储 每个新闻的关注数量表
新闻id(主键) 关注数量
用户 id 和 新闻 id 组成联合主键。
redis 中,每个新闻id作为key,关注数作为value,存储简单的键值对。
用户关注了某个新闻,发来请求:
- 同步更新数据库中的用户关注新闻表,由于用户关注新闻表每个用户会均摊行锁压力
- 更新 Redis 缓存,
INCR 新闻id
(注意catch住缓存不可用的异常) - 将新闻 id 与 用户 id 写入 MQ,请求返回。
之后 MQ 消费更新数据库这个新闻 id 的关注数量,根据消息中的新闻 id 将每个新闻的关注数量表的关注数量加一:
- 考虑 MQ 重复消费,则可以将 MQ 新闻id与用户 id组合作为 key 存储进入 redis 并设置超时时间(setnx ex),如果不存在则没放入,如果存在则不放入。这里还是有一种极限情况,就是放入 redis 但是没更新数据库进程重启,并且在 key 过期前又消费了这个消息,那么就会认为这个消息已经消费了。
- 同时还需要针对新闻id做queue以及线程分区(就是同一个新闻总是对应特定的queue以及线程,尽量每一个行锁一个线程更新,也就是尽量避免多线程更新数据库表的某一行,避免数据库 lock wait timeout)
- 另外,分区大小最好是 2 的 n 次方,因为对于 2 的 n 次方取余相当于对 2 的 n 次方 - 1 取与运算,比取余快很多很多。
怎样获取 top 10:定时任务扫描数据库每个新闻的关注数量表,按照新闻关注数量排序获取 top10,直接放入缓存。用户请求都是读取这个缓存。虽然实时性差,但是能满足需求。
读取某个新闻的关注数量:这个就读 redis 缓存,缓存不可用,读取数据库 用户关注新闻表 (使用 select count(1) ... group by),注意如果读取数据库那么最好加上本地缓存防止压垮数据库。
获取某个用户关注的新闻列表:这个读取数据库 用户关注新闻表,如果感觉也有性能瓶颈,对于每个用户id添加缓存保存关注的新闻列表即可,用户发来关注新闻请求的时候同步更新。