阿里终面:10W+TPS ,我的浏览记录系统如何设计?
共 8424字,需浏览 17分钟
·
2023-10-23 08:51
胖虎和朋友原创的视频教程有兴趣的可以看看:
(文末附课程大纲)
背景
用户的浏览行为并发量极高,在下单前用户会浏览多个商家、多个商品,在内心反复纠结后下单。电商系统浏览记录的写入量往往在订单量的十倍甚至百倍,如此高的写流量并发对于系统的挑战可想而知。
最近团队里接了一个需求,要求扩展浏览记录的品类,除了支持门店的浏览记录,还要支持商品、视频等内容信息的浏览记录,未来接入更多类型的浏览记录。值得一提的是用户只能查看30天以内的浏览记录,30 天以外,无需归档保存。新改动的需求场景,预估写入流量比门店维度会高出十倍。高峰期的写入流量预估达到了100K TPS+
在 App 主页也需要透出浏览记录,支持用户分页查询。考虑到用户主动查询浏览记录频率不高,读取浏览记录的 QPS 在1K-10K级别。
从读写场景分析,浏览记录的写流量要高于读流量,但是高峰期读写都会超过10K QPS,所以存储中间件的选取非常重要。
存储模型选型
接下来我将从数据可靠性、数据成本、读写性能等方面 探讨存储系统的选型。
从存储容量上看,用户的浏览的店铺、商品假设每天访问 10 个(保守估计),存储 id和时间戳 大概占 20个字符,一共 200B。浏览记录支持 30 天,假设 1 亿(保守估计)活跃用户。那么全量的数据规模大致为20 * 10 * 30 * 100000000/(1024**3) = 558 G
。保守估计30 天的浏览记录在500G 以上。
指标\中间件 | Redis | Redis + MySQL | MySQL | Tair |
---|---|---|---|---|
成本 | 高 | 中等 | 中等 | 低 |
数据可靠性 | 不可靠 | 可靠 | 可靠 | 可靠 |
读性能 | 极高 | 极高 | 差 | 高 |
写性能 | 极高 | 低 | 差 | 高 |
实现难度 | 低 | 高 | 高 | 低 |
首先应该排除两个方案, 纯Redis和纯 MySQL方案
-
纯 Redis 成本高昂,500G以上 的内存存储成本太高。最重要的是无法保证数据可靠性。存在丢失数据的风险。 -
纯 MySQL方案在高峰期 读写流量的性能无法保证。且每天需要归档数据,系统实现难度高。
其次来看 Redis+MySQL 方案也是不行。
-
写入时依然需要同时写入缓存和数据库,数据库抗 10W 写入流量,稳定性压力还是非常大的。对系统的挑战巨大 -
数据库存储了全量的浏览记录,需要每天归档数据。也就是系统流量除当天的写入流量,还要包括归档删除的流量。
综合分析下来,Tair 在成本、数据可靠性、读写性能、实现难度方面都比较优异。
Tair最初是诞生在淘宝,定位是作为数据库之前的缓存,最开始用在了淘宝的用户中心;我们花了5年时间,将Tair从缓存演进到分布式KV存储兼备,场景也全方位覆盖了互联网在线业务,例如从淘宝的用户登陆、购物浏览、下单交易、消息推送等等都会访问Tair。
Tair 可支持持久化的存储,同时实现了Redis 的原子化协议接口,我司基于 Tair 进行了改造。可保证100K的读写性能。后来的线上压测在2W QPS 查询时 TP999 能有 5ms的优异表现,实在让我刮目相看。更多的黑科技大家可自行百度 Tair 即可。
接下来大家可简单认为 Tair 是实现数据持久化,读写性能稍差的 Redis。
2024最新架构课程,对标培训机构
存储结构设计
用户各种类型的浏览记录,要支持分页查询,有多种实现方案。为了读者便于理解,我先基于redis分析,最后再对比Tair和redis区别,选择Tair合适的方案。
List结构
List结构最简单,使用List存储用户的商品浏览记录,将最新浏览记录插入到List头部。
LPUSH KEY_NAME VALUE1
新增记录:LPUSH ${userId} {$productId, $viewTime}
分页查询,需要指定起止offset, 截止offset= offset + count即可
LRANGE ${userId} ${offset} ${offset}+${count}
Redis List LPUSH新增时,会在队列头部新增,这样保证第一条浏览记录是最新的。使用LRANGE 可以指定范围查询浏览记录。
Tair的List结构和Redis类似,并且Tair支持 元素维度的超时。但是当前Tair只能在队列尾部新增记录,这样第一条记录是最早的浏览记录。分页查询最新浏览记录只能从后倒序查询。但是遗憾的是Tair的查询只能从头开始,正序查询。所以是无法支持从最新浏览记录分页查询的场景。
Sort Set结构
ZADD KEY_NAME SCORE1 VALUE1
新增浏览记录: ZADD ${userId} ${viewTime} ${productId}
ZREVRANGE key start stop
分页获取数据: ZREVRANGE ${userId} offset count WITHSCORES
sort set支持 设置分数和value。在浏览记录中,可以使用 时间戳作为Score,商品Id等作为Value。分页查询时使用ZREVRANGE
倒序分页获取浏览记录。
redis无法支持Sort Set
元素维度的过期。30天以上数据的删除,需要归档任务。
由于Tair也没有Sort Set
数据结构,所以Sort Set
方案也被抛弃。唯一剩下的只剩下 Hash结构。
由于List和Sort Set
无法支持,最后只剩下了Hash结构
Hash结构
Hash
结构较为复杂,总共三个方案。难点在于无法优雅的实现分页查询最新浏览记录。
方案\key | key | hash field | hash value |
---|---|---|---|
方案 1 | ${userId} | ${商品 id} | 时间 |
方案 2 | ${userId} | ${day} | json 存储 productId和时间 |
${userId} | day_info | json 存储 productId和时间 | |
方案 3 | ${userId} | ${day} | json 存储 productId和时间 |
${userId}_dayinfo | ${day} | 当天的访问总数 |
方案 2 和方案 3 都需要存储用户每天的浏览记录总数,区别在于 方案 2 把每天浏览数量存储在 Hash field 中。方案 3 把每天浏览数量存储在redis 大 key,且每天的数据存在Hash Field
,即小key中。
接下来将分析各个方案的优缺点。
方案 | 写入 | 分页查询 | 过期 | |
---|---|---|---|---|
方案 1 | 写入原子 | 分页查询:需要一次性取出30 天的浏览记录 | 支持hash field 过期 | |
方案 2 | 并发写入问题。同时每天的浏览次数+1时,需要更新整个大JSON,网络耗时比较高。 | 首先取出用户每天的浏览数,根据当前页数,计算取出哪天的浏览数据。无需取出全量浏览数据 | 支持hash field 过期 | |
方案 3 | 并发写入问题。同时更新每天浏览次数时,只需要更新当天的数据即可 | 首先取出用户每天的浏览数,根据当前页数,计算取出哪天的浏览数据。无需取出全量浏览数据 | 支持hash field 过期 |
由于方案 1 ,分页查询时需要取出全部的浏览数据在客户端进行分页,当用户的浏览数据非常大时,一定会出现慢查询。同时客户端进行分页在 高并发场景下,内存的消耗也是比较高,系统 GC 压力会非常大。综合下来,方案 1 并不合适。
为了进行分页,要想一个好办法。如果把每天的浏览次数记录下来,每次分页时查询每天浏览次数,排序,最后根据当前页数计算要取哪几天的数据。这样好处是不需要取全部的浏览记录,只需要取某几天的浏览记录。
方案3和方案2都是这样的思路,但是方案3比方案2 要更加优异。要知道每次浏览行为都要更新当天的浏览次数,方案2 把所有的浏览次数放到一个大JSON中,每次更新都要更新一个大JSON。而方案3 把每天浏览记录打散放到Hash的Field中。明显写入时,性能更高
但是方案3也有劣势,把每天的浏览次数打散放到Hash中,计算分页数据时,需要全部取出Hash的所有Key。而方案2只需要取Hash的一个Field。两者的差异真的很大吗?其实不然……方案2、3都需要取出30天的浏览次数。区别在于查询一个HashField和查询Key的区别。从查询数据量上并未差异。
由于只记录了30天的数据,且只记录每天的浏览次数,所以一个Field Value 数据量并不大。例如2023.09.09共21条浏览记录,存储上field:"20230909",value: 21, 一共10个字符。30天一共300个字符。查询300个字符,对于Redis压力并不大。20230909还可以压缩,可以使用上线时间为基准时间,计算距离这一天的差值,一般不超过4位数9999。这样又可以把10个字符降为5个字符,这样一个Hash结构共150个字符,数据也不大。
业务场景决定了写流量远大于读流量。所以分页查询时每次取150个字符获取每天的浏览次数,然后计算从哪几天中获取浏览记录列表。整体上查询的耗时可控。
当然也会存在问题,例如某一天用户的浏览行为非常多,浏览的商品和门店信息都存储下来,分页查询时,查到这一天时因为浏览门店非常多,所以就会对系统造成较大压力。这种极端情况下不能完全记录用户浏览行为。如何应对也需要和产品沟通协调,是否限制记录每天的浏览记录数量,例如限制阈值30个。避免极端情况导致慢查询,影响集群性能。
截止目前基于Hash结构的方案3 暂时满足我们的诉求,但是还有一个问题需要解决。并发写入问题。
因为当天的浏览记录都存储到了一个hash Field中。当一个用户频繁更新时,可能导致同时更新同一个key。如何解决呢?
如果是Redis,可以手写LUA脚本,实现Hash Field更新的CAS原子操作。即先查询当天浏览次数,然后更新当天的浏览次数+1,如果成功则记录浏览行为。如果浏览次数大于阈值,则丢弃这次浏览行为。
但是Tair既没有实现Hash Field的CAS操作,也不支持LUA脚本,我只能想其他办法。
Tair实现了Key Value维度的CAS更新,是否可以使用userId+day作为key,浏览次数作为value呢? 不可以,因为用户量级太高。上亿用户 * 30天。将近30亿-300亿的KEY, Tair系统压力也是比较大。
如何确保用户维度无并发呢?
分布式锁方案
使用userId维度分布式锁,写入量级在10W TPS。
不作控制
如果出现并发问题,容忍数据丢失问题。实际上用户的两次浏览行为基本上会在秒级别,系统基本不会出现并发访问。
浏览行为消息 通过UserId进行分片路由。
浏览行为是通过Kafka通知的,如果把userId的消息全部路由到Kafka的一个分片,同时保证有序消费,就可以保证单个用户的浏览行为都是串行处理的。
综合分析开发成本,我们认为暂时不进行并发控制,因为我的浏览行为数据并不是强一致性要求的数据,即使有丢失对于用户也没有损失。同时并发修改的概率还是比较低的。我们认为 并发写入的风险可控。
总结
我的浏览数据典型的高并发写入、低并发查询场景。我们要从数据可靠性、成本、系统实现难度、读写性能等多个方面全面评估。落实到存储结构时,需要考虑存储系统支持的特性是否满足。
综合分析下来,让我意识到我的浏览行为 是一个对存储系统挑战极大的场景。有一个稳定可靠性能强大、功能强大的存储系统真的可以简化 业务逻辑的实现。
胖虎联合两位大佬朋友,一位是知名培训机构讲师和科大讯飞架构,联合打造了《Java架构师成长之路》的视频教程。完全对标外面2万左右的培训课程。
除了基本的视频教程之外,还提供了超详细的课堂笔记,以及源码等资料包..
课程阶段:
Java核心 提升阅读源码的内功心法 深入讲解企业开发必备技术栈,夯实基础,为跳槽加薪增加筹码
分布式架构设计方法论。为学习分布式微服务做铺垫 学习NetFilx公司产品,如Eureka、Hystrix、Zuul、Feign、Ribbon等,以及学习Spring Cloud Alibabba体系 微服务架构下的性能优化 中间件源码剖析 元原生以及虚拟化技术 从0开始,项目实战 SpringCloud Alibaba电商项目
点击下方超链接查看详情(或者点击文末阅读原文):
(点击查看) 2024年,最新Java架构师成长之路 视频教程!
以下是课程大纲,大家可以双击打开原图查看