共计 4991 个字符,预计需要花费 13 分钟才能阅读完成。
最近在自己的工作中,把其中一个 PHP 项目的缓存从以前的 APC 缓存逐渐切换到 Redis 中,并且根据 Redis 所支持的数据结构做了库存维护功能。缓存是在业务层做的,准确讲应该是在 MVC 模型中 Model 的 ORM 里面。主要逻辑就是先查缓存,查不到的话再查数据库。不过这些不是本文的主要内容,下面我把库存管理功能的缓存设计思路分享一下,希望能带给大家一些收获,有不足之处或者有更好方案的,也希望各位多多指教。
一、业务背景
为了略去我们公司项目背景,我决定把这次的问题类比成一个考卷上的问题。至于业务细节,大家也无需关注~ 看题目就可以了:
假设你是某国最牛的收藏家,手里有各种价值连成的宝物。知道有一天,你觉得做收藏太没意思了,打算把这些宝物卖掉换点现金。
不过把这些值钱的宝贝放在菜市场上卖实在太 low 了。在“互联网 +”时代,我们当然要玩一些不一样的卖法:在你名下有一栋 300 个房间的大楼(编号为 001 至 300),每个房间放着一个密码锁保险箱,在下个月(12 月 1 日至 12 月 31 日)的每一天,你都会挑选 300 件最好的“极品宝物”(也称作 A 类宝物),分别放入这 300 个房间的保险箱里,每天每个房间放什么宝物已经定好了,所有想买宝物的人必须至少提前一天在网上预定,到时候凭借预定码自己打开保险箱取货。没有被预定的宝物将会被你收回,不再售卖。
要做这样一个网络预定系统,它的前端界面大概是这样的:
上图中三个要填的控件,单击后可以出现选择框。现在的问题是,一个房间只有一个宝物,不能被重复预定。所以当买家选择了宝物类型和房间号之后,在选择预定日期时,要在日期选择框给用户一个提示。比如 12 月 3 日 051 号房间已被预定,现在又有另一位用户选择了 051 号房间,那么在弹出日期选择框时,12 月 3 日要置为不可选。如下图(12 月 3 日显示为“缺”):
那么,这样一个简单的库存系统,如何在 redis 中存储呢?
二、库存管理方案(Redis)
最粗暴的想法是,我们的库存其实就是一个很大的三维数组,第一维宝物类型,第二维房间号,第三维即预定日期。Redis 支持 5 种存储类型:String,Hash,List,Set,Sorted Set。目前的场景中 Hash 和 Set 类型都可以满足要求,在此我们选择使用 Hash 类型做存储。
Redis 的 key 设置为 宝物类型 + 房间号(例如 A:205,A 代表极品宝物,205 为房间号),Redis 的 value 为 hash 类型,hash key 为日期(例如 2016-12-05),hash value 为 true 或 false,表示已经被预定或没有被预定。用图表示为:
如果 A 类宝物 158 房间在 12 月 8 日已经被预定,则存储为
Redis Key —— A:158
Redis Value —— hash table [‘2016-12-08’ => 1]
三、进阶场景 & 库存管理方案
你所推出的 A 类极品宝物很受欢迎,刚推出去不久即被预定出去很多。然而,动辄数十万元的价格也让很多有收藏兴趣、却没那么富裕的中产阶级望而却步。于是,你又从自己的收藏中挑选出了比 A 类宝物稍次一些的 B 类宝物(也称作“优质宝物”),价格更加亲民。
由于 B 类宝物比 A 类宝物多一些,你打算换一种玩法,在这 300 个房间中,每个房间又放入了一个保险箱,这次,你每隔一个小时都会向 300 个房间的箱中各放入一件 B 类宝物,没有被预定的宝物在这一个小时过后会被收回,换成下一个小时的宝物。买家预订后,按照所预定的小时来取走宝物。对于 B 类宝物,你的预定系统会多了一个选项,即取货时间。如下图:
现在由于多了一个预定条件(取货时间),那在做库存存储的时候,粗暴的方式想一下,库存其实就是一个大的四维数组。第一维宝物类型,第二维房间号,第三维预定日期,第四维取货时间。在 Redis 中怎样存储这类宝物呢?
其实仔细想一下,在存储 A 类极品宝物的时候,我们在 Redis 中的存储是有浪费维度的情况的,
当时 hashValue 只存了一个 true 表示有预定,这个维度其实是被浪费掉了。考虑到取货时间全是整点,一整天也就是 0 至 1 点,1 至 2 点,……,23 至 24 点共计 24 种情况,所以我们完全可以使用二进制整数表示被预定的时间。例如 1 表示 0 至 1 点,2 表示 1 至 2 点,4 表示 2 至 3 点,……,
8388608(= 2^23)表示 23 至 24 点。多个时间段被预定,只需要将数值取逻辑或操作即可。
这样,我们的 Redis 结构变成了这样子:
例如,B 类宝物 103 房间,12 月 5 日和 6 日的上午 8 点至 12 点被预定,在 redis 中存储为
Redis Key —— B:103
Redis Value —— hash table [‘2016-12-05’ => 3840, ‘2016-12-06’ => 3840]
对于 B 类宝物,在做新增预定时,需要注意先将原有的 hash value 取出,和新的预定取货时间做逻辑或操作,然后再把结果写回 Redis 中,而不能像 A 类宝物一样直接调用 hSet 去设置 hash value;取消预定时,要注意先将原有的 hash value 取出,把要取消的时间段从 hash value 中扣除掉(异或 + 逻辑与操作),然后重新将剩余的已预订取货时间写回 Redis 中,而不能直接调用 hDel 去删除。
四、再次进阶 & 库存管理方案
自从推出了 B 类宝物之后,你的生意又比以往火爆了许多。于是新的需求又来了,现在有大量的游客、学生党等没什么丰厚积蓄的人表示对你的宝物非常感兴趣,来这个城市旅游的人都希望带一些纪念品回去。然而,B 类宝物的价格虽然比 A 类便宜一些,对于这些人来讲还是有点贵。于是,你决定把自己余量最多的实惠宝物(C 类宝物)拿出来售卖。
这部分宝物数量是最多的,于是你在这 300 个房间中,每个房间新增了 100 个宝箱,专门用于存放 C 类宝物。这 100 个宝箱分别被编号为 1 号,2 号,……,100 号。同样的,每天的每个小时,你都会向这 300 个房间中,每个房间的 100 个宝箱中分别放入一件 C 类宝物(也就意味着,整个大楼每小时 C 类宝物会更新 30000 件)。如果没有人预定,则下一个小时宝物更换。终于,这下可以满足所有人的需求了。
对于 C 类宝物,你的预定界面成了下面的样子:
我们又多了一个预定条件。此时,又面临着库存存储的问题。照例,这个库存其实就是一个大的五维数组,宝物类型、房间号、预定日期、取货时间、宝箱编号各自占有一个维度。不过前面我们的 Redis 各个维度基本上已经占满了,这次应该怎么存储呢?
这次的 Redis 库存存储必须要结合业务特点来了。首先,宝箱编号和取货时间这两个维度,能取的值范围并不太多,宝箱编号只有 100 个,只要把 hash value 变成一个长度为 100 的数组,数组的每个位置都存有 INT 类型表示的取货时间即可。然而 hash value 只能是 string……于是乎,只好做一个数组的序列化操作,读取的时候再反序列化回来即可。好在长度只有 100,序列化效率并不会成为系统的瓶颈。
例如,C 类宝物,12 月 23 日、24 日,258 房间,97 和 99 号宝箱在 11 点至 13 点被预定,则存储为:
Redis Key —— C:258
Redis Value —— hash table [‘2016-12-23’ => ‘[97 => 6144, 99 => 6144]’, ‘2016-12-24’ => ‘[97 => 6144, 99 => 6144]’ ]
其中 6144 用二进制表示为‘110000000000’,hash value 为数组序列化以后的字符串,实际项目中可以使用 json 格式。好了,现在 Redis 对于三种宝物的存储都有了。
对于 C 类宝物,在用户取消预定、新增预定时,同样不能简单地调用 hSet 和 hDel 进行覆盖设置和删除,要取出已经预定的情况,与已经预定的取货时间做位运算。
五、存储优化
库存理论上就是一个多维数组,我们所做的主要工作就是怎样把各个维度合理的存储起来,并能够方便地进行增加、删除、查询操作。从节约使用内存的角度讲,在最开始还没有任何人预定的时候,Redis 整个可以是空的,对于 A 类宝物来说,hash value 等于 false 和根本不存在对应的 redis key 或 hash key 是等效的。
另外,宝物类型和房间号合起来做 redis key,会导致我们在 redis 中和宝物库存相关的 key 的数量比较多,为了方便统一管理这些 key,可以再增加一条 redis 缓存,专门用来存储和宝物库存相关的所有 redis key 值,如下图所示。需要注意的是,这次我们并不需要 hash 数据类型了,set 类型就已经足够,增删改查复杂度都是 O(1)。里面存储了所有 redis 中已经存在的库存 key 值。
这么做的一个好处是,万一哪天碰到一些特殊情况,需要把所有库存相关缓存全部清空的话,我们可以很容易地取出所有的库存 key 并做删除操作。另外一个好处是,给我们提供了继续扩展的思路……设想一下,现在最复杂的情况是 C 类宝物,一共 5 个维度。假设未来,你不再使用一幢楼的 300 个房间去售卖宝物,而是多幢楼,那么用户在下订单的时候又要多出一个维度——楼栋编号。碰到这种情况,我们完全可以将这个多出来的库存 Key 集合退化为楼栋编号来使用,保证了可能出现的更复杂情况下的扩展性。
在做了这次扩展之后,每次新增预定记录时,需要注意检测库存 key 集合中是否已经存在对应的 redis key 值,如果不存在需要将 redis key 值加入库存 key 集合中。删除操作也类似。
六、总结
上面使用了循序渐进的方法讲述了一下问题,不过现实的场景中,这三种宝物类型在我们的业务中是同时存在的。上面的设计保持了三种宝物类型存储上的统一性。如果只考虑 A 类宝物的话,库存只有三个维度,其实完全不必使用 hash 数据类型来存储,set 类型就足够了。
我们存储这些预定情况的主要目的,就是为了方便快速地查到库存冲突情况。比如有人已经定了 12 月 3 日,59 号房间的 A 类宝物,那又有另外一个人想预定一样的日期、房间的 A 类宝物时,通过内存中的库存查询��我们可以很方便地告诉客户,该库存已经被其他人抢先预定了。
以上就是我在业务中碰到的一个缓存设计的小问题,不吝赐教!
下面关于 Redis 的文章您也可能喜欢,不妨参考下:
Ubuntu 14.04 下 Redis 安装及简单测试 http://www.linuxidc.com/Linux/2014-05/101544.htm
Redis 主从复制基本配置 https://www.linuxidc.com/Linux/2015-03/115610.htm
Redis 集群搭建与简单使用 https://www.linuxidc.com/Linux/2017-03/142210.htm
CentOS 7 下 Redis 的安装与配置 https://www.linuxidc.com/Linux/2017-02/140363.htm
Ubuntu 14.04 安装 Redis 与简单配置 https://www.linuxidc.com/Linux/2017-01/139075.htm
Ubuntu 16.04 环境中安装 PHP7.0 Redis 扩展 https://www.linuxidc.com/Linux/2016-09/135631.htm
Redis 单机 & 集群离线安装部署 https://www.linuxidc.com/Linux/2017-03/141403.htm
CentOS 7.0 安装 Redis 3.2.1 详细过程和使用常见问题 https://www.linuxidc.com/Linux/2016-09/135071.htm
Ubuntu 16.04 环境中安装 PHP7.0 Redis 扩展 https://www.linuxidc.com/Linux/2016-09/135631.htm
Ubuntu 15.10 下 Redis 集群部署文档 https://www.linuxidc.com/Linux/2016-06/132340.htm
Redis 实战 中文 PDF https://www.linuxidc.com/Linux/2016-04/129932.htm
Redis 热迁移实战总结 https://www.linuxidc.com/Linux/2017-02/141083.htm
Redis3.0 配置文件详解 https://www.linuxidc.com/Linux/2017-03/141369.htm