写点什么

转转商品系统高并发实战(数据篇)

  • 2022 年 8 月 08 日
  • 本文字数:4242 字

    阅读完需:约 14 分钟

概要

商品系统作为电商中的核心系统之一,其重要性不言而喻。互联网业务上的高性能、高并发、高可用在商品系统上体现的淋漓尽致。除了引入分布式缓存以及分库分表的优化之外,本文从数据的角度阐述了商品系统的优化,以提高商品系统的并发能力和性能。

转转商品服务的现状

转转在业务架构的划分上采用的是大中台小业务的方式以实现业务的快速迭代,商品系统作为业务中台的核心系统之一,承载了所有业务方的商品业务。


在数据库的设计上,商品系统采用了分库分表的策略,共拆分了 16 库,每个库 16 表的策略来提高数据库并发操作能力。此外,也根据业务类型对商品表做了一个垂直拆分,以降低索引树高提高查询性能以及降低锁冲突概率提高更新性能。


同时引入分布式缓存,提高服务的并发能力。在缓存的使用方式上,采用的是 Cache Aside Pattern 策略。


背景及存在的问题

随着业务的发展,越来越多的业务方接入商品服务,不断拔高的 QPS 给系统带来的压力越来越大。同时转转作为全品类的二手闲置交易平台,其囊括了包含 C2C、B2C、C2B、B2B、C2B2C 等多种业务模式,在此场景下商品的数据模型要设计的足够通用化以承载不同的业务模式,而在一些模式下需要展示的信息较多,也就导致了单商品记录的数据比较大。


由此带来的三个矛盾越发的突出,即:


  1. 不断拔高的 QPS 和系统高可用,高性能之间的矛盾

  2. 调用端的 GC 压力和商品数据较大之间的矛盾

  3. 加机器的成本与降本生效之间的矛盾


总的来说核心矛盾就是如何在尽量低的成本下提供更好、更快的服务。

定位优化点

明确了核心矛盾之后,在优化点的定位上,遵循以下原则:


  1. 抓大放小。在服务治理平台上可以看到,商品读的调用量是远远大于商品写的,因此本次优化只针对商品读

  2. 完成路径分析。在一次商品读的完成路径上,每个点都可以作为优化的点。

  3. 可行性分析。对优化点进行可行性分析,以保证优化的效果


商品读的完成路径就是商品信息查询的一次 RPC 调用链路,其简化的流程如下图所示<br />



<br />一次 RPC 调用,本质上就是数据的获取与流动,数据的获取上利用缓存以降低耗时,提高性能。那么在数据流动上,是否可以考虑降低数据包的大小以减少序列化和反序列化的耗时、数据传输耗时以及数据的内存占用呢?


如上图,Server 收到 Client 的请求之后,从 Redis 获取商品数据流,反序列化成对象,接着序列化成数据流,传输到 Client,Client 获取到数据流之后,反序列化成对象,如果能降低数据包的大小,那么整个过程中 Client、Redis、Server 都将受益。


如何降低数据包的大小?关键的点有两个,那就是数据压缩和减少无效数据的传输


在数据压缩上,可操作的空间已然不大,序列化的协议已经承担了很多。那么是否能考虑减少无效数据的传输以降低数据包的大小?换个说法来讲,就是 Client 的每次调用是用到了接口返回的全部字段数据?还是仅用到了部分字段数据?如果是仅用到部分字段数据的话,是否就可以只返回调用方使用到的字段数据从而减少无效数据的传输以降低数据包的大小?


根据上述的分析,我们定位到了减少无效数据返回的优化思路,下面对其可行性进行分析

优化点可行性分析

如下图列出了部分调用方查询商品信息所用到的字段数接口返回的字段数。可见,大部分调用方用到的字段数实际上远远小于接口返回的字段数。也就是说调用方拿到了类似商品描述这样的大字符串,却没有使用到,这无疑增加了不少系统间的压力。



针对这一分析,下面提出两种优化方案

优化方案

方案一:为 TOP5 调用量的调用方单独提供查询接口


如上图,为 TOP5 调用量的调用方单独提供查询接口,在接口中对调用方不需要的字段数据进行过滤,只返回调用方需要的数据。


这里需要明确两个问题:


  1. 为什么只对 TOP5 调用量的调用方单独提供接口?

  2. 在优化上是否能满足预期?


对于第一个问题,实际上也是遵守了抓大放小的原则,TOP5 的调用量占比全部调用量的 50%以上,只对 TOP5 的调用方提供单独的查询接口,这可以在成本和效果之间取一个均衡。


对于第二个问题,是否满足预期从两个角度考虑:


  1. 无效数据是否被过滤?由于商品信息存储在 Redis 采用的是 String 数据结构,只能整存整取,所以从 Redis 中获取的数据并非全为有效数据。然后从商品服务返回给调用方的数据经过了过滤,可以达到全部为有效数据的效果

  2. 通用性与扩展性如何?作为商品业务中台,在满足业务方业务的同时,也要保证能力的通用性与扩展性,避免与业务方强耦合从而疲于被动修改。单独给业务方提供接口,返回的字段需要和调用方商定,这显然已经与调用方发生了强耦合。如果调用方需要增加返回的字段信息,那么接口就得跟着改造


这个方案的优点是实现简单,只需要在原有的接口上封装一层即可,但是后期维护成本高,且没有做成全链路的无效数据过滤。

方案二:标记字段请求法

GraphQL 是一种用于 API 的查询语言,它可以不多不少的请求你所需要的数据,并且可以在一个请求中获取多个资源,这样一来,即使是比较慢的移动网络下,使用 GraphQL 的应用也能表现的足够迅速。<br />



<br />参考这一设计理念,商品系统的设计方案如下

方案概要


如上图,调用方标记需要用到的字段,这些字段可以跨表,然后商品系统根据调用方标记的字段去 Redis 或 Mysql 去查询,其返回字段的决定权由调用方决定,商品系统只提供通用的查询能力。


可以看到这种方案的优点为:


  1. 减少定制化开发接口带来的成本

  2. 按需查询,按需返回,减少链路中无效数据传输和 GC 带来的时间成本

  3. 多表字段路由,调用方无需调用多个接口来拼接数据


缺点就是需要标记请求的字段,这在一定程度上增加了请求数据包大小,下面聊聊该方案的实现细节。

请求字段的标记

标记一个字段,一个较为可读的表示方式就是传入该字段的名称,但字符串占用内存相对较大,在数据传输和序列化上对性能都会有一定的损耗。针对这一问题,我们采用另一种利用 bit 位标记一个字段的方式。<br />如下图,long 型共 64 位,前 2 位表示组的信息,后 62 位表示字段的信息,这就可以表示 4*62=248 个字段的信息,完全满足接口当前以及未来的需要 <br />



当前商品系统共用到 57 个标记位


举个例子,用 long 型右数第一位表示商品状态,右数第二位表示商品标题,那么字段表示如下:


long status = 1;long title = 1 <<< 1;
复制代码


若调用方请求标记了商品状态商品标题两个字段,就可以这么计算:


long result = status | title;
复制代码


如此便可让 long 型的右数第一位和和右数第二位皆为 1,当商品系统拿到这个组合 long 型值的时候,也就知晓了调用方的所需字段。


当然用 bit 位标记字段有一定的复杂性,为保证正确性和易用性,这里可以利用建造者设计模式隔离构造的复杂性,让调用方更方便的使用,如下所示:



BitProductFieldRepresentation fieldRepresentation = new BitProductFieldBuilder() .actTypeId() .infoType() .brandIdNew() .build();
复制代码

按需查询的实现

要实现按照标记的字段查询数据,在商品信息的请求链路中,需要分两部分来讨论,即 Redis 的按需查询和 Mysql 的按需查询。

Redis 的按需查询

目前商品信息在 Redis 中存储的数据结构是 String,其中 key 为商品 id,value 是序列化后的整个商品信息,String 数据结构是整存整取,无法按需查询。<br />Redis String 数据结构适用于每次需要访问大量的字段且存储结构具有多层嵌套的场景,而 Hash 数据结构更适用于在多数情况下只需要访问少量字段的场景,且需要知道访问哪些字段。<br />在我们的业务场景下,Hash 数据结构显然更为合适,因此将商品存储在 Redis 的数据结构修改为 Hash 类型,即可实现按需查询字段信息。


Mysql 的按需查询

根据统计,在请求中,Redis 的命中率可达 98.5%以上,Mysql 的命中率仅为 1.5%,Mysql 的命中率较低,对其做按需查询所带来的优势不大。


并且 Mysql 的查询性能不及 Redis,因此这里放弃做 Mysql 的按需查询,避免降低 Redis 命中率,增大数据库压力。

表路由

在请求标记法中,可以标记不同表的字段,以实现跨表查询。在批量查询商品信息的逻辑中,需要根据缓存的命中情况,对商品 id 进行路由,对没有命中缓存的商品 id,路由到相应的队列中从数据库中进行数据获取。


如下图,status 位于基础信息表、price 位于价格表、stock 位于库存表,在查询三个商品 ID 的 status、price、stock 的数据时,在 Redis 中 product1 未命中 status 的数据,product2 未命中 price 的数据,product3 均命中,那么就会将 product1 路由到基础信息表数据待获取队列中,product2 路由到价格表数据待获取队列中,后续由单独的线程去对应的数据表并发查询数据,然后组装结果返回。


扩展性

这里参考 Spring 的 BeanFactoryPostProcessor,BeanPostProcessor 提供了一些扩展点,在无需改动主流程情况下,提供扩展能力。如下图,在接收到请求参数之后,参数的校验以及 bit 位请求字段的解析都是放在扩展类中实现的


优化效果

从四个角度来看优化的效果,即调用端 GC 情况服务端 GC 次数和 GC 耗时网卡流量接口耗时<br />此数据是在促销调用的场景下,TPS 为 3500,调优化后的接口返回 14 个字段的数据与调优化前接口返回 50 个字段的数据得出<br />需要注意的是促销使用的 14 个字段在 3 张不同的表中,在完成一个事务的情况下,需要调用 3 个原接口。

调用端 GC 情况对比

调用端调用优化前接口单位时间内发生 547 次 GC,耗时 1.74s<br />调用端调用优化后接口单位时间内发生 176 次 GC,耗时 561ms<br />约 3 倍的提升<br />



服务端 GC 次数及 GC 耗时对比

调用端调用优化前接口,服务端单位时间内发生 10 次 YGC<br />调用端调用优化后接口,服务端单位时间内发生 3 次 YGC(高点 4 次,低点 2 次)<br />约 3 倍的提升<br />




调用端调用优化前接口,服务端单位时间内约 120ms 的 GC 时间<br />调用端调用优化后接口,服务端单位时间内约 40ms 的 GC 时间<br />约 3 倍的提升



网卡流量对比

调用端调用优化前接口,服务端机器单位时间内经过网卡的流量为 11.95MB<br />调用端调用优化后接口,服务端机器单位时间内经过网卡的流量为 90.62MB<br />约 8 倍的提升<br />



接口调用耗时对比

调用优化前三个接口,接口平均耗时分别为:1.17ms、1.52ms、1.23ms<br />调用优化后接口,接口平均耗时 1.30ms


总结

本文从数据的角度对商品系统进行了优化,从分析思路到具体实现做了一些简单的介绍。如果大家有任何疑问,也欢迎在文末留言,我们会进行答疑。

参考资料

  1. https://codeahoy.com/2017/08/11/caching-strategies-and-how-to-choose-the-right-one/

  2. http://hessian.caucho.com/doc/hessian-serialization.html/

  3. https://graphql.cn


转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。


关注公众号「转转技术」(综合性)、「大转转 FE」(专注于 FE)、「转转 QA」(专注于 QA),更多干货实践,欢迎交流分享~

用户头像

还未添加个人签名 2019.04.30 加入

转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。 关注公众号「转转技术」,各种干货实践,欢迎交流分享~

评论

发布
暂无评论
转转商品系统高并发实战(数据篇)_分布式_转转技术团队_InfoQ写作社区