很多业务系统构架中都有库存服务
- 库存可以以数量为单元,假如有5台手机,那么库存就是 5 ,卖掉一台,库存就减 1 变成 4 。这种以数量为单位进行的库存计算相对比较简单,在架构设计中无论你优化DB中的SQL,还是存储结构,或者引入Redis做缓存都比较好设计和实现。
- 有些库存不是以数量为单位的,比如酒店房间,这是可以重复利用的,是以天为单位的,行话叫“间夜”,理论上如果某间房间一年365天都可用,那么一年就有365个库存,一天就一个。A客人1月1号占了,B客人1月1号就不能占。当然,还有钟点房,时间粒度更细,这个先不讨论,后面会涉及。
- 还有些业务的库存是以更细粒度为单位的,比如小时。租车业务就是以小时为单位进行库存占用的,假设我们只有一辆车,A客户8:00-10:00租用,B客户可以 11:00-15:00租用。
以下的库存优化思路是针对租车这种细粒度的占用单位的
笔者几年前参与过租车业务中库存系统的改造升级,当时的库存服务主要的问题有两个
- 没有形成独立的服务,与其他服务耦合在一个“全家桶”式的系统中
- 与库存相关的业务提供的RPC服务响应较慢,而又由于库存是核心服务,所以库存一慢,各相关子系统都会拖慢
对于第一个问题,相对比较好解决,我们将库存服务独立出来,包括数据库。问题在于第二个,其实慢的原因也很明显,就是对于库存的计算完全依赖数据库,利用SQL与java代码进行计算,当然重头戏在DB,当时采用的数据库是SQL Server,幸亏是SQL Server,如果是My SQL的话可能性能撑不到我们对它进行改造的时候。随着数据量的增加,整个库存服务在完全依赖DB的情况下,服务的查询速度较慢,是秒的量级。
当时的改造先进行了一轮谨慎的尝试,即对业务进行整体重新梳理、对现存SQL,java代码进行全面优化。优化的结果并不理想,虽然这个过程让业务更清晰,也发现了些遗留问题和BUG,但是并没有解决慢的根本问题。
在第一轮的基础上想过用缓存进行处理的方案,但由于单位粒度太细,缓存的设计方案复杂又不好落地,并没有采用。在QPS和TPS趋于平稳的情况下,库存系统的问题并没有被逐渐放大,反而似乎没有到大家忍受不了的地步。于是就不了了之了。
而实际上,这件事真的就结束了吗?我想不一定
由于当时公司的系统越来越多,并且旧系统也会升级改造,随着一些业务的新增或升级,如果有突发的流量,比如做个营销活动,系统能不能撑得住?即使撑得住,用户体验会不会好?我不知道,但我知道如果一个核心服务慢了,整体的体验都不会好。
其实对于我个人来讲,那次的改造不彻底也是我的一个心结,首先当时没有明确的标准和目标,没有特别清晰的量化下来,比如库存服务优化到300-500ms。其实当时我很清楚它慢,就是没有很好的解决了那个问题,心有不甘。
于是今天想起来,提出一个方案,看能不能解决它!
整个库存服务的重点就是如何准确的计算出来它的值,如果算的不对,可能导致有车租不出去(算少了),或没车租出去了(算多了)。而计算这个值的公式特别简单:
可预定车辆数 = 现有运营车辆数 - 被占用车辆数
1 我们将现有运营车辆数以 门店_车型_amount 为 key ,车辆数为 value 存在Redis中。 当现有运营车辆数变化时,在修改DB的同时同步Redis,当然无论是DB还是Redis都是要做高可用架构设计的,以防止单点故障的发生。
2 被占用车辆原先是以数据库表的方式存储的记录,之前由于时间粒度太细,所以用SQL计算,所以慢,这里我们采用贪心算法的思路,具体是这样的:
首先按门店+车型 一对一的将占车记录取出来。如A门店C1车型,A门店C2车型。将每组每条占车记录的开始和结束时间转为Unix时间戳,最小单位是秒,如:1546315932。
再将开始和结束时间组成一个闭区间如[1546315932,1546316000],再把每组的这些闭区间组成一个二维数组。int[][]intervals。
例如:
[[1546315932,1546316000],[1546315942,1546317000],[1546315932,1546315943],[1546315901,1546315907]]
最后每一个门店+车型都对应一个占车二维数组。
将这些数组以 门店_车型_occupy 为 key ,二维数组为 value 序列化到redis中
当要计算某门店某车型的库存时,先将上面的以门店_车型_occupy为key的值取出,然后利用贪心算法计算出时间段重合的占用数量,再用门店_车型_amount为key的总量减去占用数量就得到了可用库存数。如果时间段不重合就更新Redis二维数组添加新的元素。Redis在操作期间是需要LOCK的。
贪心算法
贪心算法,特别适合解决 Interval Scheduling(区间调度问题),比如算出多个区间中最多有几个互不相交的区间;或给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。如果我们请求库存的参数中的时间与现有占车记录的有重叠,那么返回的需要移除区间的最小数量就是 >=1 的。
代码实现如下:
public int eraseOverlapIntervals(int[][] intervals) { if (intervals.length == 0) { return 0; } Arrays.sort(intervals,new Comparator<int[]>(){ @Override public int compare(int[] o1,int[] o2){ return o1[1]-o2[1]; } }); int cnt = 1; int end = intervals[0][1]; for (int i = 1; i < intervals.length; i++) { if (intervals[i][0] < end) { continue; } end = intervals[i][1]; cnt++; } return intervals.length - cnt; }
由于贪心算法是线性时间复杂度,又是在内存中计算,所以效率会比数据库高很多。
- 这里你可能会关心Reids存储的数据量,由于车辆的开放预定周期不会太久,比如半年或一年,那么分到每个门店和车型其实预定的人并不那么多,即使在未来的预定周期内所有车都被预定了数据量也不很大。
- 对于过期的数据,比如一个月以前或一周以前的不再参与库存计算的Redis数据,用定时任务清掉就可以了,或者也可以再行持久化备份一份。
- 这里讲的是单车型的,如果要算多车型的,就起多线程并行计算。
以上就是利用了缓存和贪心算法进行的缓存优化,在此基础上还应该注意:不要对原服务(依赖数据库版本)进行删除,可做为系统服务降级时使用。Redis也要做好高可用和数据库降级处理,如果Redis挂了,还可以用DB顶一下,如果数据没有了,就将整个服务降级到老版本。
需要注意的是,上面的方案我并没有在生产环境实施过,只是一种单纯的设计,如有不严谨和不对的地方,欢迎指正。如你需要解决相似的问题,请谨慎参考!