阿里终面:10W+TPS ,我的浏览记录系统如何设计?

Java专栏

共 8424字,需浏览 17分钟

 ·

2023-10-23 08:51

胖虎和朋友原创的视频教程有兴趣的可以看看


(文末附课程大纲)


👏2024 最新,Java成神之路,架构视频(点击查看)


😉超全技术栈的Java入门+进阶+实战!(点击查看)

背景


用户的浏览行为并发量极高,在下单前用户会浏览多个商家、多个商品,在内心反复纠结后下单。电商系统浏览记录的写入量往往在订单量的十倍甚至百倍,如此高的写流量并发对于系统的挑战可想而知。

最近团队里接了一个需求,要求扩展浏览记录的品类,除了支持门店的浏览记录,还要支持商品、视频等内容信息的浏览记录,未来接入更多类型的浏览记录。值得一提的是用户只能查看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方案

  1. 纯 Redis 成本高昂,500G以上 的内存存储成本太高。最重要的是无法保证数据可靠性。存在丢失数据的风险。
  2. 纯 MySQL方案在高峰期 读写流量的性能无法保证。且每天需要归档数据,系统实现难度高。

其次来看 Redis+MySQL 方案也是不行。

  1. 写入时依然需要同时写入缓存和数据库,数据库抗 10W 写入流量,稳定性压力还是非常大的。对系统的挑战巨大
  2. 数据库存储了全量的浏览记录,需要每天归档数据。也就是系统流量除当天的写入流量,还要包括归档删除的流量。

综合分析下来,Tair 在成本、数据可靠性、读写性能、实现难度方面都比较优异。

Tair最初是诞生在淘宝,定位是作为数据库之前的缓存,最开始用在了淘宝的用户中心;我们花了5年时间,将Tair从缓存演进到分布式KV存储兼备,场景也全方位覆盖了互联网在线业务,例如从淘宝的用户登陆、购物浏览、下单交易、消息推送等等都会访问Tair。

Tair 可支持持久化的存储,同时实现了Redis 的原子化协议接口,我司基于 Tair 进行了改造。可保证100K的读写性能。后来的线上压测在2W QPS 查询时 TP999 能有 5ms的优异表现,实在让我刮目相看。更多的黑科技大家可自行百度 Tair 即可。

接下来大家可简单认为 Tair 是实现数据持久化,读写性能稍差的 Redis。

2024最新架构课程,对标培训机构

👉点击查看:Java成神之路-进阶架构视频!


存储结构设计



用户各种类型的浏览记录,要支持分页查询,有多种实现方案。为了读者便于理解,我先基于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万左右的培训课程。

除了基本的视频教程之外,还提供了超详细的课堂笔记,以及源码等资料包..


课程阶段:

  1. Java核心 提升阅读源码的内功心法
  2. 深入讲解企业开发必备技术栈,夯实基础,为跳槽加薪增加筹码
  3. 分布式架构设计方法论。为学习分布式微服务做铺垫
  4. 学习NetFilx公司产品,如Eureka、Hystrix、Zuul、Feign、Ribbon等,以及学习Spring Cloud Alibabba体系
  5. 微服务架构下的性能优化
  6. 中间件源码剖析
  7. 元原生以及虚拟化技术
  8. 从0开始,项目实战 SpringCloud Alibaba电商项目

点击下方超链接查看详情(或者点击文末阅读原文):

(点击查看)  2024年,最新Java架构师成长之路 视频教程!

以下是课程大纲,大家可以双击打开原图查看


浏览 20
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报