zl程序教程

您现在的位置是:首页 >  其他

当前栏目

【项目实战】威积分首页接口开发踩坑日记

接口项目开发 实战 日记 首页 积分
2023-09-14 09:14:14 时间

一、业务功能描述

最近业务提出了修改首页UI功能的需求,原来首页都是通过店铺装修功能来实现的,但是原来店铺装修功能,
通过接口/mallapi/pagedevise?pageType=1返回给前端数据是存在局限性,返回的都是在店铺装修后数据的缓存值,即返回的内容是用户点击确认之后,记录的是系统商品当时的瞬时值。
店铺装修功能展示
下面让我们来看看这段代码,代码很简单,主要做了如下事情
(1)校验页面分类pageType(1;商城首页;2:店铺首页)不能为空
(2)获取HttpServletRequest 中HTTP请求中的Header字段,获取到客户端类型clientType
(3)根据不同的页面分类pageType以及客户端类型clientType,设置shopId店铺Id以及应用类型clientType(H5、APP、MA)
(4)根据key(由页面设计缓存前缀+租户ID+页面分类+应用类型+shopId组成的)先取Redis缓存中的对象

Object obj = redisTemplate.opsForValue().get(key);

(5)如果对象存在,即不为空,将obj转为String,并将其转为pageDevise实体
否则走Mysql数据库查询,如果查出来的内容不为空,则将内容存入Redis缓存,并且设置过期时间为5天

if (obj != null) {
     String str = String.valueOf(obj);
     pageDevise = JSONUtil.toBean(str, PageDevise.class);
} else {
     pageDevise = pageDeviseService.getOne(Wrappers.query(pageDevise));
     if (pageDevise != null) {
      //存入缓存
         redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(pageDevise), 5, TimeUnit.DAYS);
     }
 }

全部代码总览,如下图:全部代码总览
且不说,字段代码将所有的接口实现都放在Controller,这会存在着下面的历史遗留问题
(1) 业务数据更新滞后,明明已经有了最新的产品销量,但是总显示销量为0,用户看不出来最新的产品销量
(2)接口字段遗漏,首页需要增加划线价格、商品状态、卖点等功能,但在该接口返回中,并没有这些业务字段
(3)用户体验差,因为从网易严选中同步过后,很多品都是零库存的,可能会造成不好的用户体验。
(4)运营可能从后台下架了这类品,但是接口还是返回了这些品的字段值,前端依旧进行了渲染。
接口字段返回
基于以上,经过和前端同事协商,讨论出了以下两种方案。

  • 方案1 :使用定时任务
    定时任务去更新缓存的内容。保证Redis中的内容是最新的
  • 方案2:新增接口
    新增接口字段,拿到需更新的商品SPU的最新内容,并且去更新上面接口渲染出来的值

最后考虑到交付的速度,以及接口功能开发的复杂度,采取了方案2。
于是编写了如下接口
大致接口编写内容如下

(1)获取赢到前端传过来的需要查询最新记录的ID字符串数组,并将其转为集合
(2)如果集合为空,直接按照前端需要的结构返回空内容给前端
(3)遍历集合,拿到集合中的ID,再根据ID查询数据库中的内容在这里插入图片描述
(4)由于原来现成就有一个可以使用,于是就是直接将其返回了在这里插入图片描述
(5)查询的过程,见下面的Map中的内容,其中涉及到使用collection标签来实现连表查询在这里插入图片描述(6)如果查询到有内容,直接返回给前端。整个代码实现见下面
在这里插入图片描述
经过测试环境的测试,可以正常加载业务值,而且业务数据也是最新的,满足业务需要。
而且由于测试环境的业务数据量不大, 所以,整个过程页面加载的过程也很快。

二、异常现象描述

就在上线发布完后,系统接连遇到了一些不常见的问题

问题1:频繁收到java.io.IOException: Broken pipe的ELK邮箱异常提醒

当时发布完成的时候,没有很注重这个异常,因为之前导入的数据量,也会收到类似的邮箱提醒,所以没有很在意,还一度怀疑是Devops工程师的服务器配置的问题,但是总结共性,都是由mall-api的子服务发出来的。最近确实变更这个功能比较多,其中方案2的接口就写在其中。刚好那天下午,隔三差五的提醒。
在这里插入图片描述

问题2:收到了CPU跑到的短信提醒,Devops工程师和应用服务人都收到了CPU跑高的提醒。在这里插入图片描述

问题3:第三方接口无法正常调用我们的服务,提示null

我们的数据库无法正常处理业务的请求,出现服务超时的现象。
在这里插入图片描述

三、原因跟踪

步骤一、由业务功能入手,查看调用的业务接口在这里插入图片描述

查看了相应的接口中,发现这个是一个批量操作的动作,涉及的业务功能大致如下
(1)将业务数据批量插入数据库
(2)异步通过执行定时任务给相应的用户发短信,提醒用户积分已经到账

步骤二、遇事不决,先上ELK

经过排查,我们首先访问ELK的相应模块,发现我们的ELK中mall-api的日志打开需要很久,大概5分钟后,我打开了对应的ELK日志,发现了这个日志log一直在输出,检查代码发现,正是首页的接口。
ELK的日志输出趋势
重复无用的日志输出
我们得出以下结论,这个info级别的首页接口反复输出,造成了ELK日志日志呈增加趋势,随时准备爆满。

步骤三、查看info日志所处的业务代码,定位问题代码

发现了日志的出处。是此处打印出了info级别的日志。
在这里插入图片描述

注意:这里的info日志输出应该要删除的,因为系统功能刚上线,多打一些info日志还是有用的,后面系统稳定运行了,可以考虑删除info级别的,只保留error级别的。

步骤四、对此接口进行Chrome接口调用分析

无论从沙箱环境,还是生产环境,都发现了,这个接口的耗时严重,以下大致的截图
在这里插入图片描述

步骤五、定位问题代码的上下问,发现优化点

对此段代码进行问题定位。发现两个可以优化点

(1)在for循环中,做耗时的操作

goodsList.forEach(id -> {
    GoodsSpu goodsSpu = goodsSpuService.getById2(id);
    if (ObjectUtil.isNull(goodsSpu)) {
        goodsSpu = new GoodsSpu();
    }
    array.put(goodsSpu);
    log.info("array= {}", JSONUtil.toJsonStr(array));
});

我们经常会使用一些循环耗时计算的操作,特别是for循环,它是一种重复计算的操作,如果处理不好,耗时就比较大,如果处理书写得当,将大大提高效率。这里就明显遇到了for循环的问题。

其实在性能优化领域,有一个非常入门基础的问题,就是,如何在for循环调用API?那这个问题的答案是什么呢?

异步:

如果API只是请求,不用返回,异步调用是最简单的方式,
如果API请求后,需要有返回,但只果不是同步的关系,还是可以用异步。
扔给一个线程程去解决就完事了。

说明:别人调用我们的创建积分的接口,就是这样的处理逻辑,我们插入数据库,然后通过开启异步线程,去处理分发短信提醒的逻辑。

批量:

API的调用,很大一部分在于TCP三次握手的链接,所以,如果能够批次处理数据,那么性能提升杠杠的,当然,问题也在于是,如果出错了怎么办?
说明:其实这个接口在设计的时候,有考虑这一重,前端传过来的是批量的ID数组,而不是分开多次请求我们的接口,其实这也是一种接口优化的设计。

但是不够彻底,下面会讲到为什么不够彻底。
在这里插入图片描述

(2)没有使用Redis缓存中间件对结果进行缓存

原来的首页的接口都是有加入Redis中间件的,而我们这个接口,也是首页的接口,如果不加入Redis中间件肯定也是不行的,如果并发量上来了,类似于这个业务,积分发放之后,大家都跑去系统,去查看我们的积分,然而,每次访问首页,都会调用这个接口,不用问,系统数据库的内存,CPU肯定会扛不住。

(3)请求后,接口响应实体内容多而大

光是下载的内容就占了大部分时间,但实际前端调用的内容并没有很多。接口/pagedevise/getDetailByComponentReq返回数据量大,有2.9M,如果首页配置更多产品则会更大。
按照最小够用原则(或者说最小权限原则),
而这个响应的实体,又包含两个问题,
1、前端需要的内容其实单表查询就能完成的,为什么还用map去关联。能不能考虑删除多个collect的查询使用?
2、前端需要的内容应该封装成一个DTO,方便前端取数才行。
在这里插入图片描述
在这里插入图片描述

步骤六、优化代码

(1)修改单记录处理变成批量处理

前面说到接口批量请求的不够彻底,具体体现在如下代码
如果我们能够先把ID都组装在一块,然后在数据库中只查询一次的话,那返回的数据不就更好了吗?
于是有了这一块的优化。

JSONObject jsonObject = new JSONObject();
jsonObject.set("id", componentDetailReq.getId());
jsonObject.set("componentName", componentDetailReq.getComponentName());
List<String> goodsList = Arrays.asList(componentDetailReq.getGoodsList());
for (String id : goodsList) {
       ids.add(id);
}
List<GoodsSpu> goodsSpuList= goodsSpuService.getGoodsSpuList(ids);

(2)删减该接口不需要的字段,减少返回内容,将返回给前端的内容封装为DTO实体

通过与前端同事沟通,实际业务需要的字段有哪些,于是得到了如下对DTO进行封装,得到以下类GoodsSpuDTO

@Data
@ApiModel(description = "spu商品参数")
public class GoodsSpuDTO extends Model<GoodsSpuDTO> {
    private static final long serialVersionUID = 1L;
  @ApiModelProperty(value = "id")
    private String id;
    @ApiModelProperty(value = "spu名字")
    private String name;
    @ApiModelProperty(value = "卖点")
    private String sellPoint;
    @ApiModelProperty(value = "商品主图")
    private String[] picUrls;
    @ApiModelProperty(value = "销量")
    private Integer saleNum;
    @ApiModelProperty(value = "积分赠送开关(1开 0关)")
    private String pointsGiveSwitch;
    @ApiModelProperty(value = "最低价")
    private BigDecimal priceDown;
    @ApiModelProperty(value = "最高价")
    private BigDecimal priceUp;
}

并且通过 实体复制得到DTO内容,只返回 List给前端来实现缩小响应体。

 List<GoodsSpu> goodsSpuList;
 List<GoodsSpuDTO> goodsSpuDtoList;
 goodsSpuDtoList = BeanConvertUtils.convert(goodsSpuList, GoodsSpuDTO.class);
initPicUrl(goodsSpuList, goodsSpuDtoList);
if (CollUtil.isEmpty(goodsSpuList)) {
        goodsSpuDtoList = new ArrayList<>();
}
jsonObject.set("goodsList", goodsSpuDtoList);

在优化的过程中,还发现图片其实只用了第一张图片,而网易严选返回的图片可能是多张,也会占用响应的大小,因此也对返回体的图片进行了针对性裁剪。

private void initPicUrl(List<GoodsSpu> goodsSpuList, List<GoodsSpuDTO> goodsSpuDtoList) {
        for (int i = 0; i < goodsSpuList.size(); i++) {
            String[] picUrls = goodsSpuList.get(i).getPicUrls();
            String[] picFirstUrls = new String[]{picUrls[0]};
            goodsSpuDtoList.get(i).setPicUrls(picFirstUrls);
        }
    }

(3)引入Redis缓存

在操作过程中,对查询数据库后的集合进行缓存,编写出大致的逻辑。注意,Redis的转换与使用,以及缓存过期策略的使用。

//先取缓存
String key = StrUtil.format("{}:{}:{}:{}:{}:{}", CacheConstants.MALL_PAGE_DEVISE_CACHE, TenantContextHolder.getTenantId(), clientType, componentDetailReq.getId(), componentDetailReq.getComponentName(), DateUtil.format(new Date(), "yyyyMMdd"));
List <GoodsSpu> goodsSpuList;
List <GoodsSpuDTO> goodsSpuDtoList;
Object obj = redisTemplate.opsForValue().get(key);
if (obj != null) {
    String str = String.valueOf(obj);
    goodsSpuList = JSONUtil.toList(str, GoodsSpu.class);
    goodsSpuDtoList = BeanConvertUtils.convert(goodsSpuList, GoodsSpuDTO.class);
} else {
    goodsSpuList = goodsSpuService.getGoodsSpuList(ids);
    goodsSpuDtoList = BeanConvertUtils.convert(goodsSpuList, GoodsSpuDTO.class);
    if (CollUtil.isEmpty(goodsSpuList)) {
        goodsSpuDtoList = new ArrayList < >();
    } else {
        //存入缓存
        redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(goodsSpuList), 1, TimeUnit.DAYS);
    }
}

四、总结

优化后封装返回给前端的实体DTO后发现,最后响应体的大小由原来的2.7M减少成了80.3KB,而且,加了Redis缓存,以及合并数据库响应的返回,以后整个响应速度也非常快。同时,也再没有出现异常的ELK邮件提醒。
在这里插入图片描述
经验分享:
(1)对于首页这种,访问频率非常的高的接口,必须要考虑缓存,不然会造成数据库的占用很高的情况
(2)返回给前端的接口查询时,还是要协商返回的,不能一股脑将所有的字段都返回给前端,数量小的时候,还好,数据量一大,将会是灾难。
(3)不要在for循环中做耗时的操作,要尽可能转异步或者是批量处理!
(4)功能实现有很多种方案,但是怎样才是最优的方案,这才是真正要去思考的,切务边开发边设计,一定要想清楚之后才开始开发,不然还是要自己填写。
(5)最后,感谢文峰在整个复盘的过程中,对异常代码提出的优化思路,受益匪浅啊。