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

您所在的位置:网站首页 阿里销售终面 阿里终面:10W+TPS ,我的浏览记录系统如何设计?

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

2024-06-26 16:50| 来源: 网络整理| 查看: 265

背景

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

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

指标 \ 中间件RedisRedis + MySQLMySQLTair成本高中等中等低数据可靠性不可靠可靠可靠可靠读性能极高极高差高写性能极高低差高实现难度低高高低

首先应该排除两个方案, 纯 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。

存储结构设计

用户各种类型的浏览记录,要支持分页查询,有多种实现方案。为了读者便于理解,我先基于 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结构较为复杂,总共三个方案。难点在于无法优雅的实现分页查询最新浏览记录。

方案 \ keykeyhash fieldhash value方案 1${userId}${商品 id}时间方案 2${userId}${day}json 存储 productId 和时间${userId}day_infojson 存储 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 的一个分片,同时保证有序消费,就可以保证单个用户的浏览行为都是串行处理的。

综合分析开发成本,我们认为暂时不进行并发控制,因为我的浏览行为数据并不是强一致性要求的数据,即使有丢失对于用户也没有损失。同时并发修改的概率还是比较低的。我们认为 并发写入的风险可控。

总结

我的浏览数据典型的高并发写入、低并发查询场景。我们要从数据可靠性、成本、系统实现难度、读写性能等多个方面全面评估。落实到存储结构时,需要考虑存储系统支持的特性是否满足。

综合分析下来,让我意识到我的浏览行为 是一个对存储系统挑战极大的场景。有一个稳定可靠性能强大、功能强大的存储系统真的可以简化 业务逻辑的实现。

·END·



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3