软件重构实践:当30w日活遇上25QPS接口

1,424 阅读12分钟

大家好,我是 方圆。在对 京东延保服务频道页 建设时,前期复用的是原有查询接口,但是该查询接口性能有限:压测分析最大QPS为 25,且接口响应时间较长。为了业务推广时提供稳定的查询能力,遂对该接口进行了重构优化。本篇文章主要讲解重构优化的过程并去除了其中有关业务的知识,主要关注软件设计和代码整洁。

1. 接口逻辑概述

首先,先来简单的介绍下原有接口逻辑,如下图所示(以查询用户最近的 200 条订单为例):

在这里插入图片描述

  1. 并发查询用户订单列表(200 条订单的查询任务平均分配)
  2. 根据列表数据并发查询订单明细
  3. 根据明细信息并发查询绑定关系
  4. 过滤出 3c 订单的绑定关系,封装结果返回(目前频道页只为 3c 品类商品服务)

2. 为什么 QPS 只有 25

还是以查询 200条 订单为例,观察订单量在各个步骤中的变化情况,如下:

在这里插入图片描述

查询订单列表页得到 200条 订单,那么接下来会对这 200条 订单分别查询订单明细,再分别查询绑定关系,最后筛选出 3c 订单封装结果并返回。可以发现,订单量在三个查询操作的步骤中都是不变的,那么在并发查询的情况下,对订单相关查询的QPS转化率为 1:200+(也就是说请求这个接口一次,会请求订单相关接口至少200次),由于转化过高,对订单相关接口查询压力过大,最多支持 25 QPS的查询。

但是实际上,我们想要的只是 3c 订单,假如这 200单 中只有 10单 为 3c 订单的话,那么本次调用有 190次 调用都是无效的。这时候我就在想,如果能在查询完订单列表时,将所有非 3c 订单过滤掉,那么将极大的减少对订单查询的压力,订单量变化如下图所示:

在这里插入图片描述

在查询完订单列表时过滤出仅有的 10 条 3c 订单,那么这样对订单相关接口的QPS转化为 1:10+,在相同的情况下,对订单相关接口查询的压力降低为原来的 1/20,接口的承载能力可以有效提高。

3. 为什么响应时间这么长

如下是调用该接口查询 200 条订单的耗时情况:

在这里插入图片描述

首次查询将近耗时 7 秒,其中有 6 秒都在查询订单明细;二次查询时,对查询做了缓存预热,响应时间明显缩短,但总时长仍然在 500ms 左右,且查询订单明细耗时最长。

根据以上分析可知,性能瓶颈在查询订单明细,如果能在查询完订单列表时,过滤出 3c 订单,那么便能基本上完成优化工作。

4. 重构方案设计

在着手前,先来看看原有代码的时序图(注意标红部分分别为查询列表和查询明细):

在这里插入图片描述

根据上述分析,优化查询列表的方法即可,但是发现两个问题:

  1. 因为该项目开发的较早,所以查询订单调用的仍是老接口,而该接口中不包含商品品类字段,无法根据品类信息过滤出 3c 订单。如果想完成此操作,需要改成调用新接口

  2. 查询订单明细存在冗余调用(图上查询明细调用两次),如果将查询明细的接口也替换成新接口的话,可由两次调用减少为一次

针对第一个问题,理论上我们将这个步骤重写,并将后续方法中订单列表对象更换即可完成优化,但是在处理第二个问题时,发现原接口中两个订单明细对象从头串到尾,代码耦合极深(batchQueryBindRelationAndBuild 是很长的私有方法),若替换成新接口,将返回的新对象替换老对象的开发工作不亚于重新开发。所以,权衡开发成本和后续的可维护性、可扩展性,便选择了重构。

外观模式

首先,我想到的便是外观模式,定义通用的外观查询接口,如下:

在这里插入图片描述

为什么要这么写呢,考虑如下情况:

在这里插入图片描述

在商城内针对延保的展示有两种,一是展示两个推荐延保,二是分组展示所有延保。在外观接口中根据现有展示的样式定义 通用的查询能力,这样,我们并没有站在需求的角度上定制开发,而是提供了通用实现,在后续扩展新的补购页面时,能够按需选择,实现 接口复用和解耦

单一职责和松耦合的设计

现在已经定义好接口的外观方法了,那么内部该如何实现呢?其实具体逻辑已经很清楚了:

  1. 查询订单列表
  2. 查询订单明细
  3. 查询绑定关系
  4. 封装结果并返回

这时候我就在想:这些 查询方法 需要抽出一个专门的 Service 来定义,不仅仅是为了实现单一职责的设计原则,更多的是为了后续的可复用和松耦合设计实现可插拔。

复用:如果在外观下新增补购页面查询方法,那么我们定义的这些查询方法可以直接拿过来用,避免重复开发

松耦合的可插拔设计:比如说后续不需要请求订单明细便可以通过订单列表对象查询绑定关系的话,那么我们直接将查询明细的方法移除即可,改动较小

如下是 ReplenishmentQueryService 的实现:

在这里插入图片描述

此外,针对封装返回值的方法也专门提出了 ReplenishmentBuildService 实现,也是为了满足上述设计原则:

在这里插入图片描述

最终代码逻辑如下:

@Resource
private ReplenishmentQueryService queryService;
@Resource
private ReplenishmentBuildService buildService;

public Response replenishmentRecommand(Request req) {
    // 1. 查询列表页
    queryService.listOrderInfo();
    // 2. 查询订单明细
    queryService.listOrderDetail();
    // 3. 通过明细查询绑定关系
    queryService.listBindRelationByOrderInfo();
    // 4. 封装结果并返回
    return buildService.buildRecommandInfo();
}

优化后代码时序图如下:

在这里插入图片描述

Redis 锁控制订单请求数量

延保服务频道页最多展示 10条数据,如下图所示,轮播展示的订单数量是有限的:

在这里插入图片描述

而查询订单明细和绑定关系时又是多线程并发查询处理的,当某用户有大量 3c 订单时,为了控制查询数量不超过 10 便使用了 Redis 锁来实现数量控制的逻辑,如下图所示:

在这里插入图片描述

如果某用户有 20 条 3c 订单,那么他首先会向 Redis 中保存一个数量为 10 的计数器,随后开启多线程查询订单明细和绑定关系,每查到一条有效数据便去获取 Redis 锁,获取到锁时便对计数器执行减 1 的操作,直至计数器为 0 便不再执行查询逻辑,提高效率的同时保证查询不超过 10 条订单(通过实践,这种方案相比 JVM 级别的锁效率更高)

《图解Java多线程设计模式》中对于线程的互斥提出了比较具有参考价值的点:在对线程进行互斥处理时需要考虑 “要保护的东西是什么”,这样便能够清晰的确定 锁的粒度。在上述例子中,要保护的是该用户在某时刻的查询计数器,那么为 用户账号 + 时间戳 加锁是合适的

查询可配置化

为了增加接口的灵活性:能够根据流量调整查询的订单量,对查询订单数量、查询订单的天数和最终展示的订单数量等等参数,针对不同的渠道定义了不同的配置来满足业务要求和提高查询效率,最终的代码逻辑如下所示:

@Resource
private ReplenishmentQueryService queryService;
@Resource
private ReplenishmentBuildService buildService;
@Resource
private ConfigService configService;

public Response replenishmentRecommand(Request req) {
    // 0. 获取渠道配置信息
    Config config = configService.getByChannel(req.getChannel);
    // 1. 查询列表页
    queryService.listOrderInfo(config);
    // 2. 查询订单明细
    queryService.listOrderDetail(config);
    // 3. 通过明细查询绑定关系
    queryService.listBindRelationByOrderInfo(config);
    // 4. 封装结果并返回
    return buildService.buildConvergeVo(config);
}

优化结果同样是查询用户的 200 条订单,首次调用和二次调用的性能耗时情况如下:

在这里插入图片描述

接口优化上线后响应时间平稳,基本稳定在 150ms 以内,相比先前 500ms 响应时间提升明显。

为了准确的评估接口性能,我们做了埋点对实际的请求数据进行分析:用户平均有效订单数为 2 - 3 单,这就表示对查询订单明细接口的 QPS 转化接近 1:3,结合压测结果,QPS能提高到 750 左右,保证对订单相关接口的 QPS 压力在阈值范围内,相比先前 25QPS 提效显著。

5. 优化后复盘

在做完这些优化时,我一直在问自己一个问题:究竟什么样的接口能被称为设计的比较好的接口呢?

在这里我想借助《软件设计哲学》这本书中对良好接口设计的描述:设计较好的接口通常能提供强大的功能但是接口签名非常简单的,这样的接口被称为“深”的。什么是“深”的呢,如下图所示:

在这里插入图片描述

如果用矩形来表示接口的话,则矩形的面积与接口提供的功能成正比;矩形顶部边缘表示接口公开出的签名,边缘长度越长则表示接口签名越复杂。较“深”的接口,因为其内部复杂性只有很小一部分对调用者可见,所以称它设计较好。那我们设计的接口够深吗?我们看一下接口签名:

Response replenishmentRecommand(Request req);

public class Request {
    // 渠道
    private String channel;
    // 用户pin
    private String pin;
    // 要展示的一级类目
    private List<Long> mainFirstCategoryList;
    // 查询的页码
    private Integer pageNo;
}

请求对象 Request 字段并不多,好像看上去只有 channelmainFirstCategoryList,需要花精力来了解它们的用途,其他再无干扰使用该接口的因素了。但是实际上,要想真正发挥出该接口的最好的性能,channel 在这个接口中涉及的知识还是较多的。

上文中我们提到过:为了增加该接口的灵活性引入了查询配置,不同的渠道对应的查询配置不同,比如延保服务频道页和其他补购面对的流量是不同的,所以它们的查询配置也是不同的,而这个配置信息贯穿了接口的始终,如下所示 config 嵌入到了相关查询方法中:

public Response replenishmentRecommand(Request req) {
    // 0. 获取渠道配置信息
    Config config = configService.getByChannel(req.getChannel);
    // 1. 查询列表页
    queryService.listOrderInfo(config);
    // 2. 查询订单明细
    queryService.listOrderDetail(config);
    // 3. 通过明细查询绑定关系
    queryService.listBindRelationByOrderInfo(config);
    // 4. 封装结果并返回
    return buildService.buildConvergeVo(config);
}

它用来控制在各个查询节点查询的数量、并发的线程数量和最终要展示的结果等等,如果新增渠道,那么需要添加对应的配置才行,而想添加合适的配置,那么便需要了解配置中的字段在这个方法中是如何用的,起到了什么作用,使配置的复杂性渗透到了其他方法中(复杂性下沉),这无疑增加了其他用户使用该接口的难度,使得这个接口变“浅”。

开发的时候我也考虑到了这个问题,我用了两种办法来避免这个复杂性:

  • 详细的配置注释:如果将配置的每个字段描述的足够清楚,那么使用该接口的研发人员不需要去了解代码便能知道根据渠道如何添加一个合适的配置了
  • 提供默认配置:在压测期间,根据压测结果得出了一套通常情况下性能较好的查询配置,那么在某渠道未进行配置的情况下,使用该默认配置,这样在大多数情况下,新增渠道便不需要增加新的查询配置了,也无需再去了解相关代码实现,降低了使用该接口用户的认知负荷

实际上,我认为还有一点能对这段逻辑进行优化,如果查询配置嵌入每个查询方法的话,那么它与查询方法耦合太深,使得后续看代码的同事需要钻到每个方法的实现中去看配置是如何生效的,可以进行如下修改:

public Response replenishmentRecommand(Request req) {
    // 0. 获取渠道配置信息
    Config config = configService.getByChannel(req.getChannel);

    // 1. 查询列表页
    Request1 req1 = new Request1();
    req1.setXx(config.getXx());
    queryService.listOrderInfo(req1);
    // 2. 查询订单明细
    Request2 req2 = new Request2();
    req2.setXxx(config.getXxx());
    queryService.listOrderDetail(req2);
    // 3. 通过明细查询绑定关系
    Request3 req3 = new Request3();
    req3.setXxxx(config.getXxxx());
    queryService.listBindRelationByOrderInfo(req3);
    // 4. 封装结果并返回
    return buildService.buildConvergeVo();
}

将查询配置从每个方法中提取出来,封装到每个方法的查询参数中,并且请求参数中字段注释明确的话,便无需再深入其实现查看具体逻辑了,实现解耦;而且针对每个查询方法而言,对于其可插拔的特性,在项目其他地方复用该方法时,无需再构造查询配置对象,只需按需封装查询参数即可,降低方法复用难度。


欢迎大家在京东商城内搜索 “京东延保” 跳转延保服务频道页~