记一次系统优化经历 -php 系统
背景
需求背景是这样的:
商城有一批自营店,自营店在商城的商品库存数据和店铺自己的erp账户是打通的(erp是一个saas版的系统,供多家使用)。
虽然自营店铺在平台上是一个一个独立的店铺,各自的erp账户也是独立的,但是实际上他们的库存商品都属于公司,业务上也有一个共同的boss。
基于此,自营店的负责人希望平台能提供一个智能分单功能,根据公司仓库实际的库存情况,将自营店的订单按仓库能处理的数量拆分成多个订单,不再考虑仓库实际属于哪家自营店,只要是公司的仓就统一共管。
产品基于此,提出了一套产品规则。
一个仓库下有多个spu商品,一个spu商品会对应多个商城的sku。
商城订单在拆单时,提交一个或者多个sku,这些sku根据所属spu匹配库存。
仓库的排列顺序,要求按实际到收货地址的地图行车距离由近到远排序,两个仓库之间距离如果小于一定的阈值则认定为同一组仓。
在仓库分组后,按照由近到远的方式一个组一个组的匹配库存,进行拆单。
一个组内的仓库,优先匹配能出货最多的仓。
技术背景
因为一些历史原因和工期的限制。
考虑开发一个独立的服务负责拆单,在创建订单的时候,执行拆单逻辑。
基于此,对拆单动作的时效性要求就非常高。
整个拆单流程中有两个耗时比较严重的地方。
第一、获取仓库、库存、和换算商品spu。
因为仓库数据、库存数据包括spu关系都在erp系统中不断流转变化,拆单服务没办法独立缓存这部分数据。
每次请求进来,都要查询一次db。这部分的查询基本上耗时200ms多点,其中仓库耗时100ms左右,库存耗时100ms左右,spu查询耗时根据商品的多少,不过一般都不超过20ms。
第二、因为仓库需要排序和分组。就涉及到计算仓库到收货地址,以及仓库之间的距离。
这里的距离并不是坐标点之间的直线距离,而是根据地图接口返回的实际行车距离。这就要求每次计算距离都要请求一次地图接口。
每次地图接口请求基本耗时在10ms左右。
难题
实际生产中,仓库的数据大概在100个左右。
根据业务的场景,如果按照常规的phpfpm的阻塞模型运行,耗时大概是这样的。
注意图中,两次根据仓库数量循环。每循环一次大概需要10ms。这样算下来,仅仅是计算一个收货地址到每个仓的距离,就需要100*10 = 1000ms = 1s。
每个仓两两之间计算距离,大概需要4950 * 10ms = 49500ms = 49.5s。
这个耗时,同步接口是绝对不可能用的。
优化第一步:并行化查询
先考虑查询仓库、库存、和spu的问题。
因为这些数据基本只能查库获取,没法cache。
按照php-fpm的阻塞模型,这个时间基本没法改。不过还好,拆单系统采用了php的hyperf框架。hyperf基于swoole开发,能够给php提供协程特性。
我们利用这个协程特性,将查询仓库、查询库存、查询spu的工作并行化,这样这块的时间消耗基本等于其中最大的一个。
时间消耗变成:
参考:hyperf-parallel特性、swoole-waitgroup特性
优化第二步:缓存
有了第一步,只是解决了问题的很小一部分,最大的麻烦还是在距离计算上。
不过好在,距离的计算是可以缓存,坐标点之间的行车距离一般是不会变的。
解决这个问题的时候,我们要求业务端在用户进入下单页面的时候,提前将用户的收货地址信息提交给拆单系统。拆单系统提前解析收货地址的坐标,以及该坐标到每个仓库之间的距离并缓存到redis中。
另外,做一个定时任务,每一小时起来计算所有现有仓库坐标之间距离信息,也缓存起来。
因为缓存的key是由实际的坐标经纬度组合出来的,所以不用考虑仓库坐标数据的更新,当然实际上仓库更新地理坐标的情况基本没有。
通过上述操作,将每次距离计算从10ms,压缩到了1ms左右,这1ms是一次redis的get请求时间。
优化第三步:内存缓存
有了第二步的处理,系统速度有了比较大的改观。但是按照每个仓两两之间计算距离,仓库与收货地址之间计算距离的要求,实际上要请求将近5000次redis(业务上每个仓基本都是全国配货)。
每次1ms,5000*1ms = 5000ms = 5s,也还是达不到可用标准。
这个时候,就考虑一下实际的业务场景。实际场景中,仓库数据虽然有100多,但是实际的物理仓库是达不到100个的,因为很多仓库都是公用仓,只是每家自营店在用的时候都会添加一条仓库数据。
那么就是有很多仓库坐标是相同的,我们可以想办法减少这部分数据的缓存io。
这里我们考虑在内存中缓存这部分数据。
因为hyperf框架是常驻内存运行,static变量是不会在请求结束后释放的。借助这个特性,我们将从redis取到的数据,存储一份到内存中,每次取距离数据的时候优先获取内存中的数据,其次再去获取redis中的数据。
这里因为仓库的距离数据并不是十分巨大,所以内存占用能接受。
通过这个手段,我们将redis的读取耗时大幅降低,实际运行中看到的结果基本可以减掉一半的redis io。
不过这个操作也导致我们的拆单服务,变成了有状态的服务,这在设计上是不友好的。
会有几个隐患,一方面如果仓库、收货地址数据暴增,内存的占用会十分可观,需要慎重;另外,内存缓存如何清理,清理频率怎么设计都需要慎重。
优化第四步:计算剪枝
上一个步骤虽然减少了io量,但是副作用比较多。
而且几千次的计算,php本身的耗时也比较客观,最好还是有其他办法能减少io。
通过业务分析,我们了解到,仓库基本都在各个城市分布,一个城市一般最多分布一到两个仓库。
之前计算仓库之间的距离主要是为了进行仓库分组。将距离比较近的仓(低于配置的距离阈值),合并到一个组,组内出货最多的仓优先匹配。
那么如果 仓库A到收货地址的距离 - 仓库B到收货地址的距离 > 距离阈值,是不可能分到一个组的。
如上图,按仓库与收货地址的距离排序,A、B、C、D四个仓的排列顺序是 D、B、A、C。
其中,D到收货地址的距离是10,C到收货地址的距离是20,分组的阈值是5。那么C和D是不可能分到一个组的,因为即使D、C和收货地址在一条直线上,C和D的距离也超过了5。
基于这个想法,我们在第一次将仓库按与收货地址的远近进行排序之后,按照仓库到收货地址的距离预先进行分组。
图例中,分组后的结果是[D] [BA] [C]。
之后,再循环每一个组,根据组内仓库两两之间的距离进行计算,超过阈值的再分成独立的组,从而减少了仓库之间距离的计算量。
实际情况中,因为仓库基本都分布在不同的城市之间,配置的距离阈值是20公里,所以这样的操作能将大部分仓库都预先分配到不同的组内,大大的减少了距离计算量。
后记
经过上述的几个操作,我们把原来需要几十秒才能做完的接口,控制在了300ms以内,基本达到了业务可接受的范围。
如果后续能针对仓库、库存等增加缓存,那么相信性能还能进一步提升。
这里有两个问题有待优化,一方面我们一次访问redis耗时在1-2ms,这个速度不怎么令人满意,后续还是要继续挖掘这块性能,看到这里的朋友如果有什么想法也可以留言告诉我。
我们使用的redis是阿里云的集群版,redis操作基本就是简单的get操作。
还有一个就是php创建对象,100个仓库对象创建,耗时接近80ms。之前没细究这个时间,看到耗时之后很惊讶,这个创建速度确实让人头大,这怎么敢大规模的采用面向对象的写法呢。不知道其他语言是个什么状态,知道的小伙伴也可以留言告诉我。
评论