工作小锦囊系列——如何实现一个车辆预定功能(下)

背景

距离该话题的上篇《工作小锦囊系列——如何实现一个车辆预定功能(上)》的发布已经过去两个月了,在此之间,很多小伙伴留言催更 —— 可别满处乱跑啦,快写下集吧,村头的厕所可没纸啦~

是啊,这一拖就是两个月,恍惚还在昨天一样。

在这里有必要向关注我的小伙伴说一声 Sorry,让你们久等了。

有时候我也会反思,为什么写个东西这么慢呢?思来想去,主要还是因为太懒。

如果允许说点客观原因的话,可能是因为对剧本不满意吧。此刻草稿箱里已经趴着二十几篇文章了,有刚起了题目的,也有的写了一大半的。之所以没选择发布,可能觉得还不够完美,又或者在准备素材的过程中,发现了某本大神的书讲的真心不错,然后一股脑儿扑进去了。

看完书以后再来审度自己写的文章 —— 啥也不是。

不过如果站在「渡人」角度讲的话,现在拿出来的东西水平是远远不够的。但是如果站在「渡己」的角度讲,也不能算是白忙活。

有时候想想,懂点哲学真好,总能让人自我安慰。

正所谓:渡人先渡己,渡己先渡心,天若不渡,人需自渡。

踩个刹车,还是让我们一起来看看如何使用 Redis 实现之前的车辆预定的功能吧。

Why is Redis?

为什么要选择 Redis 呢?

这当然是和它的优势分不开的,与关系型数据库相比,Redis 本身是基于内存的,处理速度更快。而且还有丰富的数据类型,能够满足各类应用场景。特别是数量级相对较大时,Redis 相对于关系型数据库的优势,更是十分明显的。

当然,也不是说数据库解决不了的或者难以解决的问题,Redis 都可以迎刃而解。这还要看具体的应用场景。

说点题外话,一般我们如果想借助新的知识领域解决我们现有问题的时候,往往会经历两个阶段:问题到理论的正向论证和理论到问题的反向推演。直白点讲,就是带着问题到理论中寻找解决方案,和立足理论,反向推演可能解决的问题。前者属于根据问题找答案,后者属于根据答案猜问题。不管采用哪种方式,最重要的是在问题和理论之间找到某种联系,也就是论据。

接下来就让我们一起看看如何选择合适的数据结构。

如何选择数据结构

要想选择合适的数据结构,首先我们需要明确我们处理的「核心」是什么。

在上集中我们在结尾提了一嘴,没错,就是集合。

利用集合思想处理问题就是先确定子集,然后确定集合运算方式(交差并补等),最后通过子集间的运算得到目标集合。

在 Redis 的数据结构中,支持集合运算功能的类型有SetZSet(当然String类型的BITOP命令也可以实现Bitmap数据之间的集合操作)。

在上集的结尾部分,我们提到了使用Bitmap的解决方案。当时只是从经验的角度,觉得Bitmap在处理海量数据是否存在的问题上应该可以胜任。但是在经过实际考虑之后,发现使用Bitmap解决存在一些问题。所以在本章节,我们暂且不讨论Bitmap的实现方式。

既然不考虑Bitmap,就剩下SetZSet了,应该选择哪一个呢?

其实,上面我们说的「核心」是处理过程,在实际应用中,还有一个很重要的部分,即使用过程。在我们这个场景中,我们还需要将目标集合展示给客户端,而展示不可能将集合中的所有元素一次性展示出来,这就涉及到分页的问题,即根据偏移量和分页大小来获取数据。

Set在集合运算支撑方面没有问题,但是在数据获取方面就要略逊一筹了。Set并没有分页获取数据的命令,而ZSet可以通过ZRANGEZRANGEBYSCORE命令来分页获取数据。所以,从这方面来看,ZSet更适合我们的场景。

尽管Set可以通过SSCAN命令进行迭代元素,但是并不适合做分页处理。如果有条件,我们会单独拿出一篇文章来讲,这里就不展开讨论了。

方案设计

敲定了数据类型问题,接下来我们思考一下实现方案。

注意,这只是方案的探索阶段,并不代表着最终的确定方案。在这个阶段,我们尝试梳理出一个大概的思路,保证整体实现思路没有问题即可。另外,还需要抛出一些问题,留给后面的「可行性验证」和「方案实现」部分讨论。

我们使用集合处理的核心思想是:已知车辆的全集 A,车辆预定记录的集合B,则可预定车辆的集合 C = A - B

所以,基于这个核心思想,我们拆分一下具体的实现思路:

  1. 维护一个车辆的全集 A
  2. 维护一个时间周期 t 内不可预定的车辆集合 Bt
  3. 求某段区间 t1~tn 内可预定的车辆集合,可以先求该区间内不可预定车辆的集合 B = Bt1Bt2Btn
  4. 则该区间内可预定的车辆集合 C = A - B
  5. 使用ZRANGE或者ZRANGEBYSCORE分页获取数据

基本思路已经确定,看上去没有什么问题。但是有些细节问题,需要我们在接下来的环节进一步确认:

  1. 用有序集合存储数据能支持到什么数量级?
  2. 当数量级变大时,对命令响应时间对影响是否明显?
  3. 是否会产生bigkey的问题?
  4. 对 Redis 整体的内存分配是否要求很高?
  5. 有序集合求并集最多能支持多少参数?
  6. 有序集合本身并没有直接求差集的命令,差集操作如何实现?
  7. 如何对车辆信息进行筛选?

是不是对一下子抛出这么多疑问感到恐慌?莫慌,历史的经验告诉我们,前期准备的越充分,获得胜利的把握性就越大。

如果用经典的「二八定律」来解释这种行为的话,就是我们应该花 80% 的时间去思考,20% 的时间去实现。

可行性验证

接下来,从理论到实践的过车功能中,还有一件很重要的事情去做 —— 可行性验证。

想象一下,如果按照理论设计出来的方案,在理想环境下都没有问题,但是上线以后,数据量一起来,各种各样的问题接踵而至,是不是顿时有种蒙圈的感觉。

啥?啥?啥?写的这是啥???

所以,在对新的理论付诸实践之前,我们必须做好足够的功课,努力将风险降到最小。

在这一阶段,我们主要为了确认理论能够支撑的「边界」问题,即能否支撑业务的上限。因为这涉及到我们技术架构的选型、实现方式以及业务规模的预言等等。

接下来,我们就来验证一下方案设计中提到的几个问题。

关于有序集合最多能存储多少个元素问题,文档上有说明:2^32 -1 个,即 4294967295 个(有序集合和集合是一样的)。但实际上我们不光要考虑元素的数量,还要考虑值实际占用的内存空间的大小。

这里我们做了一个测试,我们用本地 Redis 进行实验。当数据库为空时,使用INFO memory查看内存使用情况,此时为 853.63K 。使用 pipeline语法尝试为Zset批量写入1万、10万和100万数据,并分别记录内存占用和响应时间情况:

Redis::pipeline(function ($redis) {
    $i = 0;
    while($i < 10000){
        $redis->zadd('ZSET_TEST', $i, $i);
        $i++;
    }
});

测试结果如下:

元素数量 占用空间大小 插入耗时 使用ZRANGEBYSCORE分页获取尾页数据耗时
1万 0.94 MB 107 毫秒 8 毫秒
10万 8.42 MB 3 秒 10 毫秒
100万 82.23 MB 30 秒 15 毫秒

注意:上述测试中使用ZRANGEBYSCORE ZSET_TEST -inf +inf LIMIT 9995 5命令获取尾页数据。因为使用分页获取数据时,时间复杂度会跟随 offset 的变大而变大,当 offset 等于有序集合的基数时,此时时间复杂度为最坏 O(N),其中 N 为有序集合的基数。

这里通过测试我们可以得到以下事实依据:

  • 百万级的一个整数序列元素的集合占用空间在 82.23 MB 左右,假设每天记录预定车辆信息的集合在 100 万左右,则一年需要的空间在 3 GB 左右,意味着我们开辟 Redis 内存空间的时候,尽量选择内存 > 4GB 的配置。
  • 对于百万级别的ZSET,使用ZRANGEBYSCORE分页获取数据,最坏的情况耗时在 15 毫秒左右,在可接受范围内。
  • 对于 80 MB 左右的 Key 来说,实际已经算是bigkey的范畴了,但是考虑到bigkey的影响是阻塞线程操作,所以可以考虑使用单独的 Redis 实例实现此业务。
  • 考虑到实际的业务场景,我们存预定记录仅保存生效中的和未生效的预定记录,即从现在到将来一段时间的预定记录。所以实际占用空间并没有我们预测的这么大。并且,实际场景中,预定业务是有时间期限的,比如:仅能选择半年以内的日期进行预订。
  • 经过测试,phpredispredisZUNIONSTORE命令均能支持到至少 1000 个子集合求并集。
  • 按条件筛选,我们可以通过先将查询结果存入到集合,然后再进行集合运算的方式实现。
  • 经测试,对元素数量 10万 的 10 个有序集合求并集,耗时约为 349 毫秒。

方案实现

在经过了「方案设计」和「可行性验证」以后,接下来就是「方案实现」了。

在这个阶段,我们主要实现方案的具体实施细节。

这里我们还是使用上篇中的例子:

假设现在有三辆车A,B 和 C 。A 全年可租赁,B 仅周六可租赁,C 周六周日和节假日可租赁。我们用下面一张日程图来表示三辆车的租赁记录:

现在我想查一下 10 月 4 日到 10 月 8 日之间有哪些可以租借的车辆。

车辆全集

首先,我们需要维护一个车辆的「全集」,这个集合主要用于集合运算的全集使用。

当初始化全部车辆信息或者新增车辆信息时,我们需要将车辆的唯一 ID (为了节约空间,最好使用自增整数 ID )存入到有序集合中,并将分值置为 0 (为什么要置为 0 ,后面会提到)。

车辆的 ID 及租赁类型如下:

车辆 唯一 ID 租赁类型
A 1 1. 全年可租赁
B 2 3. 仅周六可租赁
C 3 5. 周六周日和节假日可租赁
> ZADD CAR_LEASE_TOTAL 0 1
> ZADD CAR_LEASE_TOTAL 0 2
> ZADD CAR_LEASE_TOTAL 0 3
> ZADD CAR_LEASE_TOTAL 0 4

当移除车辆时,我们需要从有序集合中将车辆唯一 ID 移除。

> ZREM CAR_LEASE_TOTAL 4

我们还需要一个Hash结构存储车辆的基本信息。

> HMSET CAR_BASIC:1 id 1 name A lease_type 1 brand 奔驰
> HMSET CAR_BASIC:2 id 2 name B lease_type 3 brand 宝马
> HMSET CAR_BASIC:3 id 3 name C lease_type 5 brand 奥迪

不可预定车辆集合

接下来,我们需要维护一个单位时间段内的「不可预定车辆集合」。

什么叫不可预定车辆呢?

一般来说,不可预定的车辆分为以下几种情况:

  • 车辆在该时间段内已经被预定
  • 车辆的租赁类型和该时间段不匹配
  • 车辆处于异常状态
  • 其他原因导致的车辆无法被预定

Redis 的 key 命名为CAR_LEASE_UNAVAILABLE:{t},类型为ZSet,其中t为单位时间段,这里为

现在来看题目中,10 月 4 日到 10 月 8 日之间「不可预定车辆集合」是怎样的呢?

这里需要注意,2023 年 10 月 1 日到 10 月 8 日均为国庆假期,10 月 7 日为周六,10 月 8 日为周日。

所以,10 月 4 日到 10 月 8 日之间「不可预定车辆集合」分布如下:

日期 不可预定车辆 原因
2023-10-04 B B. 租赁类型和时间段不匹配
2023-10-05 B B. 租赁类型和时间段不匹配
2023-10-06 B B. 租赁类型和时间段不匹配
2023-10-07 B
C
B. 已被预定
C. 已被预定
2023-10-08 B
C
B. 租赁类型和时间段不匹配
C. 已被预定

如果另有车辆 D,类型为全年可租赁,但是因为车辆故障导致当前处于维修状态,同样不可以被预定。所以,车辆 D 也会在该时间段的不可预定车辆集合中。

将车辆添加到「不可预定车辆集合」的过程如下:

> ZADD CAR_LEASE_UNAVAILABLE:20231004 2 2
> ZADD CAR_LEASE_UNAVAILABLE:20231005 2 2
> ZADD CAR_LEASE_UNAVAILABLE:20231006 2 2
> ZADD CAR_LEASE_UNAVAILABLE:20231007 2 2 3 3
> ZADD CAR_LEASE_UNAVAILABLE:20231008 2 2 3 3

这里我们在有序集合中添加元素的时候,将元素的分值赋值为和 ID 一样的值,后面会介绍为什么这么做。

这里我们按天维护不可预定的车辆信息,如果是在程序中实现的话,我们需要在车辆初始化信息阶段、车辆被预定和车辆状态发生变化时将车辆信息添加到对应时间段的集合内。

同样地,如果车辆从不可预定变为可预定的状态,我们也需要从相应时间段的不可预定集合中将其移除。

现在,我们需要进一步思考:10 月 4 日到 10 月 8 日这一段时间内不可预定的车辆有哪些呢?

我们可以通过对该时间段内每一天的集合求并集实现,即只要某车辆在该时间段内有一天不可预定,则在整个时间段内该车辆都是不可预定的。

命令如下:

> ZUNIONSTORE CAR_LEASE_UNAVAILABLE:20231004-20231008 5 CAR_LEASE_UNAVAILABLE:20231004 CAR_LEASE_UNAVAILABLE:20231005 CAR_LEASE_UNAVAILABLE:20231006 CAR_LEASE_UNAVAILABLE:20231007 CAR_LEASE_UNAVAILABLE:20231008  AGGREGATE MAX

说明: ZUNIONSTORE计算给定的一个或多个有序集的并集。第一个参数为目标 key ,第二个参数为参与运算的 key 的个数,后面跟着对应个数的 key 。AGGREGATE可以指定并集的结果集的聚合方式。关于ZUNIONSTORE的更多使用细节可参考 [Redis 文档]

使用ZRANGE命令获取目标集合CAR_LEASE_UNAVAILABLE:20231004-20231008内容如下:

> ZRANGE CAR_LEASE_UNAVAILABLE:20231004-20231008 0 -1
1) "2"
2) "3"

可以看到,结果和我们预期的一致。

可预定车辆集合

现在求可预定车辆集合就比较简单了,之前提到过,用集合的处理思路如下:

已知车辆全集为 A,不可预定车辆集合为 B ,则可预定车辆集合 C 为:C = A - B = { x| x∈A 且 x∉B }

也就是说,通过求车辆全集和不可预定车辆的差集就可以得到可预定车辆集合了。

但是,这里有一个问题,Set有求差集的命令SDIFFSDIFFSTORE,但是ZSet好像并没有类似的命令呢,难道是设计之初漏掉了?

不用担心,这里我们可以通过另一种方式来实现类似ZDIFF(实际并没有这个命令)的功能。操作如下:

//1. 不可预定车辆集合和车辆全集求并集,分值取最大值
> ZUNIONSTORE CAR_LEASE_AVAILABLE:20231004-20231008 2 CAR_LEASE_UNAVAILABLE:20231004-20231008 CAR_LEASE_TOTAL AGGREGATE MAX
> ZRANGE CAR_LEASE_AVAILABLE:20231004-20231008 0 -1 WITHSCORES
1) "1"
2) "0"
3) "2"
4) "2"
5) "3"
6) "3"

//2. 去除目标集合中分值大于 0 的元素,得到可预定车辆集合
> ZREMRANGEBYSCORE CAR_LEASE_AVAILABLE:20231004-20231008 1 +inf
> ZRANGE CAR_LEASE_AVAILABLE:20231004-20231008 0 -1 WITHSCORES
1) "1"
2) "0"

这样我们就得到 10 月 1 日到 10 月 8 日这一段时间内可预定的车辆集合了。

分页获取数据

考虑到实际场景中,如果集合元素过多的话,我们不能一次性将集合中的所有元素返回,这时候就需要使用到分页的功能了。

ZRANGEZRANGEBYSCORE都能实现分页的功能。

使用ZRANGE获取分页数据的命令如下:

> ZRANGE CAR_LEASE_AVAILABLE:20231004-20231008 0 4
1) "1"

而使用ZRANGEBYSCORE分页获取数据的命令如下:

> ZRANGEBYSCORE CAR_LEASE_AVAILABLE:20231004-20231008 -inf +inf  LIMIT 0 5
1) "1"

二者获取分页的不同在于ZRANGE是按元素下标位置获取,而ZRANGEBYSCORE是按偏移量进行获取。通常笔者更喜欢使用ZRANGEBYSCORE,因为它可以指定分值的区间。

当然,如果降序获取的话通过对应的降序命令ZREVRANGEZREVRANGEBYSCORE获取即可,用法同上面一样。

条件搜索

有时,我们可能还有按条件检索的需求,比如:我只租借某种牌子的车。那应该如何实现呢?

这种场景放在关系型数据库中实现就是一个简单的where条件,但是放在Redis中实现的话,可能就有一些麻烦了。

这里,我们可以先按查询条件生成新的「车辆全集」,然后再进行上面的差集运算,这样就能实现了。

使用这种方式虽然思路清晰,但是每次更换查询条件,都需要从数据库重新查询数据,然后生成新的集合,操作起来比较繁琐。能不能直接通过 Redis 的数据结构实现呢?笔者目前还没想出来,小伙伴们也可以在文章后面留言讨论。我也会再考虑下这块,如果想出来了,这块内容单独拎出来讨论也是挺有意思的。

总结

至此,关于车辆预定问题的 Redis 实现方案我们就讲完了。

在上篇中,我们提到了使用Bitmap来解决,但是真正当我们去尝试的时候,发现使用ZSet更加灵活。当然,这并非意味着Bitmap不适合做这件事,只不过笔者现在还没想到很好的实现思路(等哪天如果突然开窍的话,再来个Bitmap版本的下下篇也不是不可以)。

文章主要通过 Redis 命令进行了阐述,实际应用中,可能还有更多的实现细节需要在代码中考虑,这里就不一一探究了。

其实,比起解决问题的结果,我更享受解决问题的过程。就像福尔摩斯探案一样,事实就摆在那里,证据也摆在那里,但是如果把他们串起来并推演出正确结论的话,则需要缜密的思考过程。

此时已经是 2024 年 1 月 1 日的深夜,再过一会就要到 2 号了。拖了两个多月的下篇,终于可以画上一个句号了。

2024 年,学习与分享同步进行,共勉。

感谢大家的持续关注,一起加油~

本作品采用《CC 协议》,转载必须注明作者和本文链接
你应该了解真相,真相会让你自由。
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 13

有没一种可能,比如使用 分析型数据库来处理这个?基础数据维护在普通的关系型数据库?

1个月前 评论
快乐的皮拉夫 (楼主) 1个月前

很棒。

1个月前 评论

厕所纸真的不多了

1个月前 评论

用Redis没问题,但是我还是更信赖关系型数据库,也得考虑数据持久化或者入关系型数据库。

1个月前 评论
快乐的皮拉夫 (楼主) 1个月前

哭了,终于来了,先点赞评论再看。

1个月前 评论
快乐的皮拉夫 (楼主) 1个月前

现在,我们需要进一步思考:10 月 1 日到 10 月 8 日这一段时间内不可预定的车辆有哪些呢?

这一段时间感觉写错了,是10月4日-10月8日吧。

1个月前 评论
快乐的皮拉夫 (楼主) 1个月前

根据条件去生成不同的全集的话,感觉会出现大量的大key,原本只需要维护一个总的全集,现在需要维护N个where条件的N个全集,这在性能上是不是损耗比较大。

1个月前 评论
快乐的皮拉夫 (楼主) 1个月前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!