在软件开发中,有这么一种说法:如果你有一把锤子,看到的东西都像钉子。
它的意思,是指技术人员通常只会使用自己已经知道的技术、工具、类库来解决问题,而很少会去思考、尝试新的方式,有点墨守成规。对于这一点,我也是深有体会的。工具本身不分对错,但如果不加判别就一如既往地使用,往往会导致出现解空间与问题域不对等的现象。杀鸡用牛刀固然不太对,同时杀牛用鸡刀也不合适。
1 百万集合的运算是哪种钉子?
曾经在做项目开发的过程中,有这样一个需求:对上百万的用户按人群进行精准推送。因为根据用户标签,这一百万用户可以划分为不同的人群,而同一个用户是有多个标签的,为了避免对用户重复推送,其中需要进行集合的差运算。
大概的场景需求是这样的:
首先,从一百万的用户中,根据标签X,选取对应的人群X,并进行推送处理;
其次,再根据第二个标签Y,选取对应的人群Y,但要排除人群X(避免重复推送),同样进行推送处理;
最后,根据第三个标签Z,选取对应的人群Z,并排除之前已经推送的人群X、人群Y,并进行推送处理,依次类推。
每个用户都有一个全局唯一用户标识,即UUID,正如前面ID生成器一节中所讨论的,并且固定为32个字符,由大小写字母和数字组成,以下是一些UUID示例:
B28d0fee84f24939D4f83ecEba9d7235 f9536E57c4b3bb712D431b2d4351aa7F bCed445c13cG3c463f9282320545Ab9e
这就需要用到集合的交集和差集运算,但 怎么看待这枚钉子,取决于你手上有几把锤子。
2 PHP的数组运算
PHP的数组函数里,关于集合运算的可以使用:array_diff()计算数组的差集、array_intersect()计算数组的交集。让我们先以一种小视角来看待集合的运算,当集合的元素个数很少时,或者说我们PHP程序员一般是怎么处理集合运算的。
假设,全部用户数量而不是一百万,只有5个,他们的UUID的后缀分别是: 1、2、3、4、5,前缀部分都一样。暂时简称为A1、A2、A3、A4、A5。
// 全部用户只有5个 $users = array( 'A0000000000000000000000000000001', 'A0000000000000000000000000000002', 'A0000000000000000000000000000003', 'A0000000000000000000000000000004', 'A0000000000000000000000000000005', );
并假设,根据标签X,得到的人群有3位,他们是:
// 人群X $x_users = array( 'A0000000000000000000000000000001', 'A0000000000000000000000000000002', 'A0000000000000000000000000000003', );
对A1、A2、A3进行推送后,接下来要根据标签Y筛选出人群Y,并假设人群Y有4位用户,分别是: A2、A3、A4、A5。
// 人群Y $y_users = array( 'A0000000000000000000000000000002', 'A0000000000000000000000000000003', 'A0000000000000000000000000000004', 'A0000000000000000000000000000005', );
最后,根据非重复推送的需求,我们可以结合数组的差集运算,确定第二批需要发送的人群有哪些。
// 第二批待推送的人群 $push_y_users = array_diff($y_users, $x_users);
相当于,最终运算出来的结果,只有A4和A5这两位用户。
$push_y_users = array( 'A0000000000000000000000000000004', 'A0000000000000000000000000000005', );
但是,回归到百万集合运算的真实场景,这种方式是否仍然适用?可以说是不太适用的,因为会受限于PHP运行时的内存大小。简单计算一下,一个用户的ID为32个字符,即占内存约为64B,一百万个用户,则需要约6.4G的内存。这部分还没加上人群X、人群Y以及中间运算过程所需要的内存。对于一般的PHP应该服务器来说,这比较吃紧,通常也会超出内存大小而提示类似以下错误:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 67108872 bytes) in ……
换言之,这一条大众式、基本式的解决方案不可行,在课堂上所学到的PHP基本语法在这不再适用,或者说不是永远都适用。那我们应该怎么办法?答:变则通。
3 我们最熟悉不过的数据库
让我们换种方式来解决。
既然内存不够用,那么好办,改用数据库来存储和处理即可。这是很多毕业生以及初级开发工程会采用的方式。因为他们对于数据库的使用非常熟悉,基本上在开发任何功能时,第一时间考虑使用的就是数据库。让我们来看下使用数据库的处理过程,以及它的不足之处在哪。
首先,创建一张数据库表push_record,用于纪录已经推送的纪录。push_code表示推送的代号,以区分不同批次的推送任务。
CREATE TABLE `push_record` ( `id` int(11) NOT NULL AUTO_INCREMENT, `uuid` char(32) DEFAULT NULL COMMENT '用户ID', `push_code` varchar(20) DEFAULT NULL COMMENT '推送代号', PRIMARY KEY (`id`), KEY `uuid` (`uuid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
为简化问题,继续以前面的示例数据为基础。我们不关心全部用户存在哪、也暂时不关心是如何实现标签筛选和业务推送,而重点关注、聚焦于如何进行集合的运算上。
在对人群X进行推送后,push_record表就会添加以下这4条新的纪录数据(TEST表示这次测试推送的代号)。
表6-3 对人群X进行推送后的纪录
id
|
uuid
|
push_code
|
---|---|---|
1
|
A0000000000000000000000000000001
|
TEST
|
2
|
A0000000000000000000000000000002
|
TEST
|
3
|
A0000000000000000000000000000003
|
TEST
|
然后,在对人群Y进行推送前,需要判断是否本次推送作业中已有纪录,则可以结合数据库的查询以及插入操作,完成这个需求。
// 人群Y $y_users = array( 'A0000000000000000000000000000002', 'A0000000000000000000000000000003', 'A0000000000000000000000000000004', 'A0000000000000000000000000000005', ); $push_code = 'TEST'; // 数据库模型,对应push_record表 $model = PushRecordModel(); foreach ($y_users as $uuid) { // 查询数据库表,是否已有纪录 // SELECT COUNT(id) FROM push_record WHERE uuid = 'A0000000000000000000000000000004' AND push_code = 'TEST' if ($model->isPush($uuid, $push_code)) { continue; } // 进行推送作业…… // 插入推送纪录 // INSERT INTO push_record(uuid, push_code) VALUES('A0000000000000000000000000000004', 'TEST') $model->hasPush($uuid, $push_code); }
在上面的实现代码中,已经标注了数据库操作相应的SQL语句,虽然我们没有全面实现整套代码,但根据查询和插入SQL语句,已经不难理解其背后常规的实现了。
这段代码,看起来功能是正常的,也没有明显的问题。但上线后不久,在一次大型推送中,就导致了严重的故障问题。一般在中、大型企业中,数据库库服务器与PHP前端应用服务器是分离的,即应该用服务器与数据库服务器,中间会通过内网的宽带来连接。另一方面,针对数据库服务器,特别是MySQL数据库,通常又会配置一主多从,从库会定时或及时同步主库的数据,进行备份。问题就在这样的架构背景和业务场景下发生了。
当需要进行一次大型推送时,大型推送是指待推送的人群过多,比如一百万用户中,基本有95多万的用户都需要推送,接近全量推送。而在对这95万用户推送过程中,每一个用户都需要先查询push_record表,在没有的情况下进行推送并写入一条纪录到push_record表。就这两个动作,大大增大了内部网络的带宽开销以及数据库负载。因为当插入一条纪录到主库时,这条新纪录需要同步到各个从库节点,而推送任务又是后台定时计划任务,会有短短几分钟内完成,瞬间造了内部网络的大量阻塞。与此同时,频繁查询数据库,特别越往后面等查询的数据库表行数越来越多时,查询速度变慢,查询次数过多,导致了数据库服务器压力增大。最后,导致了系统不可用。
所以,在面对一个业务场景时,在提供或设计解决方案时,要综合考虑。结合可用性、网络、系统架构、安全性等多个维度,权衡考虑。
4 Redis的集合运算
MySQL是关系型数据库,它有其特长之处,但对于集合的运算,并不是它的优势所在。既然MySQL这个工具不适用,那么应该使用哪种工具来进行集合的运算,并且尽量通过内存的方式来执行呢?
还记得我们本质上要解决的问题吗?那就是找出特定标签人群且从未推送过的用户。并且,我们要寻找更专业的工具,更加专注于大集合运算的工具、技术或者系统,因为我们有一百万以上的用户。结合身边的主流技术,不难发现,采用Redis不失为一个优秀的解决方案。
如果服务器尚未安装Redis,使用以下命令可以快速安装,并启动Redis服务。注意,当你需要安装时,可安装最新的Redis版本。
# wget http://download.redis.io/releases/redis-4.0.10.tar.gz # tar -xzvf ./redis-4.0.10.tar.gz # cd redis-4.0.10/ # make && make install # redis-server
我们使用了root账号,最后一行命令redis-server是用竽启动Redis服务的,成功安装并正常启动后,可以看到类似以下的输出。
_._ _.-``__ ''-._ _.-`` `. `_. ''-._ Redis 4.0.10 (00000000/0) 64 bit .-`` .-```. ```\/ _.,_ ''-._ ( ' , .-` | `, ) Running in standalone mode |`-._`-...-` __...-.``-._|'` _.-'| Port: 6379 | `-._ `._ / _.-' | PID: 25070 `-._ `-._ `-./ _.-' _.-' |`-._`-._ `-.__.-' _.-'_.-'| | `-._`-._ _.-'_.-' | http://redis.io `-._ `-._`-.__.-'_.-' _.-' |`-._`-._ `-.__.-' _.-'_.-'| | `-._`-._ _.-'_.-' | `-._ `-._`-.__.-'_.-' _.-' `-._ `-.__.-' _.-' `-._ _.-' `-.__.-'
这里稍微补充一下Redis的安装,以便让本节的Redis实现方案更为具体。当然,除了需要启动Redis服务外,还要确保本地环境已经安装了php的Redis扩展。关于Redis扩展的安装,则不再赘述。
接下来,看下如何通过Redis进行集合的操作,从而完成上面的业务需求。首先,启动Redis并连接。然后,分别把人群X和人群Y的用户ID添加到x集合和y集合,这时使用了sAdd操作,也可以批量添加。最后,通过sDiff操作求出y集合对x集合的差集,结果则是我们需要待推送的人群了。下面是相应的实现代码。
<?php // 连接Redis $redis = new Redis(); $redis->connect('127.0.0.1', 6379); // 人群X $x_users = array( 'A0000000000000000000000000000001', 'A0000000000000000000000000000002', 'A0000000000000000000000000000003', ); foreach ($x_users as $uuid) { $redis->sAdd('x', $uuid); } // 人群Y $y_users = array( 'A0000000000000000000000000000002', 'A0000000000000000000000000000003', 'A0000000000000000000000000000004', 'A0000000000000000000000000000005', ); foreach ($y_users as $uuid) { $redis->sAdd('y', $uuid); } // 通过Redis进行集合运算 $push_y_users = $redis->sDiff('y', 'x'); // 输出结果 var_dump($push_y_users);
运行此脚本,最终输出的结果是:
array(2) { [0]=> string(32) "A0000000000000000000000000000005" [1]=> string(32) "A0000000000000000000000000000004" }
那么如果集合的数量不是几个,而是一百万个呢?此Redis实现的方案性能会怎样?我们不妨来快速模拟试验一下。
模拟的过程是,先随机生成50万个ID,并放入到集合m,再随机生成50万个ID,并放入到集合n,然后通过Redis将集合n对集合m求差集。
echo 'Step1:', date('Y-m-d H:i:s'), PHP_EOL; // 随机生成50万个ID,并放入到集合m for ($m = 0; $m < 500000; $m ++) { $redis->sAdd('m', md5(rand(1, 1000000))); } echo 'Step2:', date('Y-m-d H:i:s'), PHP_EOL; // 随机生成50万个ID,并放入到集合n for ($n = 0; $n < 500000; $n ++) { $redis->sAdd('n', md5(rand(1, 1000000))); } echo 'Step3:', date('Y-m-d H:i:s'), PHP_EOL; // 交差集 $rs = $redis->sDiff('n', 'm'); echo 'Step4:', date('Y-m-d H:i:s'), PHP_EOL; // 打印差集结果的个数 var_dump(count($rs));
为了分析每个环节的消耗时间,我们在上面代码中分别添加了4次时间的纪录。执行后,输出结果(根据不同的服务器配置会有不同的结果)类似如下:
Step1:2018-06-24 07:55:45 Step2:2018-06-24 07:55:55 Step3:2018-06-24 07:56:04 Step4:2018-06-24 07:56:04 int(238659)
可以看出,模拟50万集合m和模拟50万的集合n,分别使用了10秒,加起来接近20秒。而关键是最后的环节,在通过Redis进行集合n对集合m的差集运算时,结果仅仅使用了1秒钟,甚至连1秒都不到!100万的集合运算在Redis的世界里瞬间就处理完了,并且在本次运行中给出了238659个ID的差集结果。
如果熟悉Redis的读者,也可以继续探索有没更好的实现方式。但相比于前面使用MySQL数据库的方案,Redis有明显的优势。
5 使用shell命令处理
对于大集合的处理和运算,如果你问PHP程序员,他们会回答使用PHP数组的array_diff()函数操作;如果你问DBA数据库管理员,他们会建议使用MySQL数据库;如果你在Redis社区发问,那么得到的回答绝大部分都是使用Redis;但如果你咨询的是一位精通Linux的极客呢?他们的建议又会是什么?
Linux是一个伟大的操作系统,这个系统里头有一个神奇的工具,那就是shell命令。shell命令可以完成很多工作、事情和任务,并且它们之间的设计、协作和使用都非常的巧妙。关于这里的百万集合处理,使用shell命令的话,我们可以怎样解决呢?
shell命令处理的数据,可以说大部分都存放在文件中。所以,第一步,我们需要把集合x和集合y的用户ID分别保存到两个文件中:x.txt文件和y.txt文件。
$ cat ./x.txt A0000000000000000000000000000001 A0000000000000000000000000000002 A0000000000000000000000000000003 $ cat ./y.txt A0000000000000000000000000000002 A0000000000000000000000000000003 A0000000000000000000000000000004 A0000000000000000000000000000005
注意,每个用户ID为一行,最后不要有多余的换行。准备好文件数据后,就可以使用grep命令来进行集合运算。
$ grep -F -v -f x.txt y.txt A0000000000000000000000000000004 A0000000000000000000000000000005
这就是shell命令实现的方案,非常简单明了。但PHP如何与shell命令结合起来呢?有两种算什么,一种是分开执行,但共享数据结果;另外一种也是比较常见的,那就是在PHP代码中通过system()、exec()、或shell_exec()函数执行shell命令。对于黏合版的实现,快速实现如下:
<?php // 人群X $x_users = array( 'A0000000000000000000000000000001', 'A0000000000000000000000000000002', 'A0000000000000000000000000000003', ); file_put_contents('./x.txt', implode(PHP_EOL, $x_users)); // 人群Y $y_users = array( 'A0000000000000000000000000000002', 'A0000000000000000000000000000003', 'A0000000000000000000000000000004', 'A0000000000000000000000000000005', ); file_put_contents('./y.txt', implode(PHP_EOL, $y_users)); // 执行shell命令,并转换结果 $rs = shell_exec('grep -F -v -f x.txt y.txt'); $rs = explode(PHP_EOL, trim($rs)); var_dump($rs);
这样,我们就可以把大集合的运算,从PHP转移到其他工具或者其他系统服务来完成,一如这里的shell命令。
回顾这一节的集合处理,我们暂时就已经提供了四种实现方案:使用PHP数组、使用MySQL数据库、使用Redis缓存、使用shell命令。这里给我们的启发是,在平时业务开发过程中,要多点思考,多储备一些不同的锤子。因为不同的场景,不同的业务量,其性质不同,需要的技术也不同,提供的解决方案不同,最终带来的影响也会不同。