PHP核心基础模块设计之你有几把锤子

在软件开发中,有这么一种说法:如果你有一把锤子,看到的东西都像钉子。
它的意思,是指技术人员通常只会使用自己已经知道的技术、工具、类库来解决问题,而很少会去思考、尝试新的方式,有点墨守成规。对于这一点,我也是深有体会的。工具本身不分对错,但如果不加判别就一如既往地使用,往往会导致出现解空间与问题域不对等的现象。杀鸡用牛刀固然不太对,同时杀牛用鸡刀也不合适。

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命令。这里给我们的启发是,在平时业务开发过程中,要多点思考,多储备一些不同的锤子。因为不同的场景,不同的业务量,其性质不同,需要的技术也不同,提供的解决方案不同,最终带来的影响也会不同。

发表评论