软件开发

PHP

PHP

PHP7 + beanstalkd 生产环境队列使用姿势

应用场景

  为什么要用呢,有什么好处?这应该放在最开头说,一件东西你只有了解它是干什么的,适合干什么,才能更好的与自己的项目相结合,用到哪里学到哪里,学了不用等于不会,我们平时就应该多考虑一些这样的问题:自己做个什么项目功能能跟 xx 技术相结合呢?这个 xx 技术放在这种业务场景下行不行呢?而不是 “学了这个 xx 技术能干嘛呢,公司现在也没有用这个的呀,学了也没用啊”,带着这样心情去学习 xx 技术,肯定很痛苦。
  队列大家都知道是将一些耗时的操作先不去做,先埋点,再异步去处理,这样对一些发邮件发短信之类的耗时操作,用户是感觉不到的,因为埋点结束,操作也就结束了,消费队列都是在服务器上做的。主要应用在短信或邮件通知,访问第三方接口订阅消息,商城的一些秒杀活动,都可以结合队列来完成。

Beanstalkd 介绍

  Beanstalkd是一个高性能,轻量级的分布式内存队列,C 代码,典型的类Memcached设计,协议和使用方式都是同样的风格,所以使用过memcached的用户会觉得Beanstalkd似曾相识。
  beanstalkd 的最初设计意图是在高并发的网络请求下,通过异步执行耗时较多的请求,及时返回结果,减少请求的响应延迟。

Ubuntu 安装

sudo apt-get install beanstalkd

配置文件

vim /etc/default/beanstalkd

查看状态

service beanstalkd status

# 命令回显 #
root@:/www/server/php/72/etc# service beanstalkd status
● beanstalkd.service - Simple, fast work queue
   Loaded: loaded (/lib/systemd/system/beanstalkd.service; enabled; vendor preset: enabled)
   Active: active (running) since Tue 2018-10-16 10:42:28 CST; 6 days ago
     Docs: man:beanstalkd(1)
 Main PID: 7033 (beanstalkd)
    Tasks: 1 (limit: 4634)
   CGroup: /system.slice/beanstalkd.service
           └─7033 /usr/bin/beanstalkd -l 0.0.0.0 -p 11300 -b /var/lib/beanstalkd

Oct 16 10:42:28 ip-10-93-2-137 systemd[1]: Started Simple, fast work queue.

配置连通性 + 持久化

ip 用 0.0.0.0 允许所有连接,靠配置安全组或防火墙去约束连接,放开 -b 参数 (默认没有持久化),内存的队列消息可以落地到硬盘 binlog 实现持久化,断电可重新读取队列消息。

vim /etc/default/beanstalkd

BEANSTALKD_LISTEN_ADDR=0.0.0.0
BEANSTALKD_LISTEN_PORT=11300
BEANSTALKD_EXTRA="-b /var/lib/beanstalkd"

beanstalkd 任务状态

状态 注释
delayed 延迟状态
ready 准备好状态
reserved 消费者把任务读出来,处理时
buried 预留状态
delete 删除状态

管理工具

亲测了很多网上能找到的 beanstalkd 工具,这两款是我最中意的了,一个命令行,一个web的。
命令行:https://github.com/src-d/beanstool
web 界面:https://github.com/ptrofimov/beanstalk_console

编程语言客户端

PHP 客户端
https://packagist.org/packages/pda/pheanstalk

composer require pda/pheanstalk

写入 job

<?php
//创建队列消息
require_once('./vendor/autoload.php');

use Pheanstalk\Pheanstalk;
$pheanstalk = new Pheanstalk('127.0.0.1',11300);

$tubeName = 'email_list';

$jobData = [
    'email' => '123456@163.com',
    'message' => 'Hello World !!',
    'dtime' => date('Y-m-d H:i:s'),
];

$pheanstalk->useTube( $tubeName)->put( json_encode( $jobData ) );

消费 job

<?php
ini_set('default_socket_timeout', 86400*7);
ini_set( 'memory_limit', '256M' );

// 消费队列消息
require_once('./vendor/autoload.php');

use Pheanstalk\Pheanstalk;

$pheanstalk = new Pheanstalk('127.0.0.1',11300);
$tubeName = 'email_list';
while ( true )
{
    // 获取队列信息, reserve 阻塞获取
    $job = $pheanstalk->watch( $tubeName )->ignore( 'default' )->reserve();
    if ( $job !== false )
    {
        $data = $job->getData();
        /* TODO 逻辑操作 */

        /* 处理完成,删除 job */
        $pheanstalk->delete( $job );
    }
}

default_socket_timeout 这个参数是一定要加的,php默认一般是 60s,假如您没有在代码里面设置,采用默认的话(60s),60s之内如果没有 job 产生,脚本就会报 socket 错误,我写的是 7 天超时,您可以根据业务去调整,记住一定要配置,网上很多搜的 consumer 脚本都没有配置这个,根本不能投入生产环境使用,这是我亲自实践的结果。
  关于 while true 是否死循环,很明确告诉你是死循环,但是不会一直耗性能的那样执行下去,它会在 reserve 这里阻塞不动,直到有消息产生才会往下走,所以大可放心使用,我的项目代码里面是使用了方法调用方法自身去实现循环的。

就是这样的代码,供参考:

    public function watchJob()
    {
        $job = $this->pheanstalk->watch( config( 'tube' ) )->ignore( 'default' )->reserve();
        if ( $job !== false )
        {
            $job_data = $job->getData();

            $this->subscribe( $job_data );
            $this->pheanstalk->delete( $job );
            /* 继续 Watch 下一个 job */
            $this->watchJob();
        }
        else
        {
            $this->log->error( 'reserve false', 'reserve false' );
        }
    }

监控 beanstalkd 状态

<?php

//监控服务状态
require_once('./vendor/autoload.php');

use Pheanstalk\Pheanstalk;
$pheanstalk = new Pheanstalk('127.0.0.1',11300);
$isAlive = $pheanstalk->getConnection()->isServiceListening();

var_dump( $isAlive );

可以配合 email 做一个报警邮件,脚本每分钟去执行,判断状态是 false,就给管理员发送邮件报警。

一些相关命令

查看 beanstalkd 服务内存占用

top -u beanstalkd

后台运行 consumer 脚本

nohup php googlehome_subscribe.php & 

查看 consumer 脚本运行时间

ps -A -opid,stime,etime,args | grep consumer.php

手工重启 consumer 脚本

ps auxf|grep 'googlehome_subscribe.php'|grep -v grep|awk '{print $2}'|xargs kill -9 
nohup php googlehome_subscribe.php &

一些总结

  php 要把错误日志打开,方便收集 consumer 脚本 crash 的 log,脚本跑出一些致命的 error 一定要及时修复,因为一旦有错就会挂掉,这会影响你脚本的可用性,后期稳定之后可以上 supervisor 这种进程管理程序来管控脚本生命周期。
  一些网络请求操作,一定要 try catch 到所有错误,一旦没有 catch 到,脚本就崩。我用的是 Guzzle 去做的网络请求,下面是我 catch 的一些错误,代码片段供参考。

try
{
    /* TODO: 逻辑操作 */
}
catch ( ClientException $e )
{
    $results['mid']    = $this->mid;
    $results['code']   = $e->getResponse()->getStatusCode();
    $results['reason'] = $e->getResponse()->getReasonPhrase();
    $this->log->error( 'properties-changed ClientException', $results );
}
catch ( ServerException $e )
{
    $results['mid']    = $this->mid;
    $results['code']   = $e->getResponse()->getStatusCode();
    $results['reason'] = $e->getResponse()->getReasonPhrase();
    $this->log->error( 'properties-changed ServerException', $results );
}
catch ( ConnectException $e )
{
    $results['mid'] = $this->mid;
    $this->log->error( 'properties-changed ConnectException', $results );
}

  job 消费之后一定要删除掉,如果长时间不删除,php 客户端会有 false 返回,是因为有 DEADLINE_SOON 这个超时错误产生,所以处理完任务,一定要记得删除,这一点跟 kafka 不一样,beanstalkd 需要开发者自己去删除 job。

参考资料

Beanstalkd Message Queue
http://ongx1e5a0.bkt.clouddn.com/Beanstalkd-share-slides.pdf

Beanstalkd FAQ 中文版
http://www.fzb.me/2015-7-31-beanstalkd-faq.html

PHP消息队列beanstalkd
https://www.kancloud.cn/daiji/beanstalkd/735176

Beanstalkd-带你玩转消息队列
https://www.imooc.com/learn/912

PHP

记一次 Laravel 项目迁移之后 Model 报错问题

  之前迁移过一个 Laravel 5.3 的网站,发布完代码,composer update 之后,能正常访问,随便点了点就再没去管它,后来在后台点击反馈模块就报错,当时在 laravel.log 看到 sql 语句是表名后面没有 s,那肯定报错啊,于是徒手在那个 Model 里面指定上 $table,解决了之后,也就没去深究,后来感觉心里越来越不安,虽然不是我写的,但没去深究,就感觉有罪恶感,于是决定重现这个问题来深入研究一下。

问题现象

数据库有数据表 feedbacks, 对应的 Model 为 Feedback.php 内部没有指定 $table.
在我本地是没有问题的,可以正确指向到 feedbacks 表,于是我从服务器上把代码打了个包,download 到本地重放,果然在本地也报错,可以断言是代码的问题。

代码是这个样子

Feedback.php
<?php 

namespace App\Http\Models;

use Illuminate\Database\Eloquent\Model;

class Feedback extends Model {

    protected $fillable = [];

    protected $dates = [];

    public static $rules = [

    ];

}

报错是这个样子

[2018-03-28 19:59:40] production.ERROR: PDOException: SQLSTATE[42S02]: Base table or view not found: 1146 Table 'xxx.feedback' doesn't exist in /www/....../vendor/laravel/framework/src/Illuminate/Database/Connection.php:333

开始排查代码

Step 1. 打印表名

在调用 Feedback 模型之前打印表名出来看看,结果是 feedback 没有 s,报错是肯定的!

$feedbackObj = new Feedback();
$table = $feedbackObj->getTable();
dump( $table );

Step 2. 进入 Model.php 排查

文件路径:/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php, 跳转到 getTable 方法
从源码很容易看出,如果我在模型里面指定了 $table,会走 if 这块代码直接返回自己设置的表名,如果我没有设置 table,肯定走的下面的自动获取表名逻辑,既然锁定了问题出在自动获取表名这里,就在 return 之前依次打印结果观察。

源码如下:

    /**
     * Get the table associated with the model.
     *
     * @return string
     */
    public function getTable()
    {
        if (isset($this->table)) {
            return $this->table;
        }

        return str_replace('\\', '', Str::snake(Str::plural(class_basename($this))));
    }

打印代码及打印结果如下:

dump( class_basename($this) );    // Feedback
dump( Str::plural( class_basename($this) ) );    // Feedback
dump( Str::snake( Str::plural( class_basename($this) ) ) );    // feedback

从打印结果来看 Str::plural( class_basename($this) ) 这一行已经出现问题了

Step 3. 继续进入 Str.php 排查

文件路径:/vendor/laravel/framework/src/Illuminate/Support/Str.php, 跳转到 plural 方法

代码很简单,获取英文单词的复数形式,用 Pluralizer 类去调用 plural 静态方法

    /**
     * Get the plural form of an English word.
     *
     * @param  string  $value
     * @param  int     $count
     * @return string
     */
    public static function plural($value, $count = 2)
    {
        return Pluralizer::plural($value, $count);
    }

Step 4. 继续进入 Pluralizer.php 排查

文件路径:/vendor/laravel/framework/src/Illuminate/Support/Pluralizer.php, 跳转到 plural 方法

if 这段代码不会走,因为 $count 默认是2,feedback 这个单词没有在 $uncountable 这个数组里面出现,两个条件没有一个成立的。
继续打印 $plural,打印结果 Feedback,这里就有问题了。

源码如下:

    /**
     * Get the plural form of an English word.
     *
     * @param  string  $value
     * @param  int     $count
     * @return string
     */
    public static function plural($value, $count = 2)
    {
        if ((int) $count === 1 || static::uncountable($value)) {
            return $value;
        }

        $plural = Inflector::pluralize($value);

        return static::matchCase($plural, $value);
    }

Step 5. 继续进入 Inflector.php 排查

文件路径:/vendor/doctrine/inflector/lib/Doctrine/Common/Inflector/Inflector.php, 跳转到 pluralize 方法。

    /**
     * Returns a word in plural form.
     *
     * @param string $word The word in singular form.
     *
     * @return string The word in plural form.
     */
    public static function pluralize(string $word) : string
    {
        if (isset(self::$cache['pluralize'][$word])) {
            return self::$cache['pluralize'][$word];
        }

        if (!isset(self::$plural['merged']['irregular'])) {
            self::$plural['merged']['irregular'] = self::$plural['irregular'];
        }

        if (!isset(self::$plural['merged']['uninflected'])) {
            self::$plural['merged']['uninflected'] = array_merge(self::$plural['uninflected'], self::$uninflected);
        }

        if (!isset(self::$plural['cacheUninflected']) || !isset(self::$plural['cacheIrregular'])) {
            self::$plural['cacheUninflected'] = '(?:' . implode('|', self::$plural['merged']['uninflected']) . ')';
            self::$plural['cacheIrregular']   = '(?:' . implode('|', array_keys(self::$plural['merged']['irregular'])) . ')';
        }

        if (preg_match('/(.*)\\b(' . self::$plural['cacheIrregular'] . ')$/i', $word, $regs)) {
            self::$cache['pluralize'][$word] = $regs[1] . $word[0] . substr(self::$plural['merged']['irregular'][strtolower($regs[2])], 1);

            return self::$cache['pluralize'][$word];
        }

        if (preg_match('/^(' . self::$plural['cacheUninflected'] . ')$/i', $word, $regs)) {
            self::$cache['pluralize'][$word] = $word;

            return $word;
        }

        foreach (self::$plural['rules'] as $rule => $replacement) {
            if (preg_match($rule, $word)) {
                self::$cache['pluralize'][$word] = preg_replace($rule, $replacement, $word);

                return self::$cache['pluralize'][$word];
            }
        }
    }

这个 function 里面 if 判断很多,通过打印锁定在这一行

if (preg_match('/^(' . self::$plural['cacheUninflected'] . ')$/i', $word, $regs))

self::$plural['cacheUninflected'] 打印结果里面发现了 feedback 这个单词,原因就是在这里了,但这个单词是怎么来的呢?

"(?:.*[nrlm]ese|.*deer|.*fish|.*measles|.*ois|.*pox|.*sheep|people|cookie|police|.*?media|Amoyese|audio|bison|Borghese|bream|breeches|britches|buffalo|cantus|carp|chassis|clippers|cod|coitus|compensation|Congoese|contretemps|coreopsis|corps|data|debris|deer|diabetes|djinn|education|eland|elk|emoji|equipment|evidence|Faroese|feedback|fish|flounder|Foochowese|Furniture|furniture|gallows|Genevese|Genoese|Gilbertese|gold|headquarters|herpes|hijinks|Hottentotese|information|innings|jackanapes|jedi|Kiplingese|knowledge|Kongoese|love|Lucchese|Luggage|mackerel|Maltese|metadata|mews|moose|mumps|Nankingese|news|nexus|Niasese|nutrition|offspring|Pekingese|Piedmontese|pincers|Pistoiese|plankton|pliers|pokemon|police|Portuguese|proceedings|rabies|rain|rhinoceros|rice|salmon|Sarawakese|scissors|sea[- ]bass|series|Shavese|shears|sheep|siemens|species|staff|swine|traffic|trousers|trout|tuna|us|Vermontese|Wenchowese|wheat|whiting|wildebeest|Yengeese)"

顺着往上找发现在 第三个 if 判断的时候执行了这一行代码,self::$uninflected 这个是关键,马上查找这个变量。

if (!isset(self::$plural['merged']['uninflected'])) {
    self::$plural['merged']['uninflected'] = array_merge(self::$plural['uninflected'], self::$uninflected);
}

在 :223 行找到了这个变量的所有值,这个变量的意思是复数是单词原形,不受影响,What?feedback 复数不加 s?顺手查了一下,百度词典,金山词霸很明确的说复数加s,有道词典没有说明,只显示 feedbacks 是名词回馈的意思,通过查了一些资料还是推荐 feedback 为复数形式。
参考资料链接:
http://www.learnenglishwithwill.com/feedback-vs-feedbacks-plural-form/

    /**
     * Words that should not be inflected.
     *
     * @var array
     */
    private static $uninflected = array(
        '.*?media', 'Amoyese', 'audio', 'bison', 'Borghese', 'bream', 'breeches',
        'britches', 'buffalo', 'cantus', 'carp', 'chassis', 'clippers', 'cod', 'coitus', 'compensation', 'Congoese',
        'contretemps', 'coreopsis', 'corps', 'data', 'debris', 'deer', 'diabetes', 'djinn', 'education', 'eland',
        'elk', 'emoji', 'equipment', 'evidence', 'Faroese', 'feedback', 'fish', 'flounder', 'Foochowese',
        'Furniture', 'furniture', 'gallows', 'Genevese', 'Genoese', 'Gilbertese', 'gold', 
        'headquarters', 'herpes', 'hijinks', 'Hottentotese', 'information', 'innings', 'jackanapes', 'jedi',
        'Kiplingese', 'knowledge', 'Kongoese', 'love', 'Lucchese', 'Luggage', 'mackerel', 'Maltese', 'metadata',
        'mews', 'moose', 'mumps', 'Nankingese', 'news', 'nexus', 'Niasese', 'nutrition', 'offspring',
        'Pekingese', 'Piedmontese', 'pincers', 'Pistoiese', 'plankton', 'pliers', 'pokemon', 'police', 'Portuguese',
        'proceedings', 'rabies', 'rain', 'rhinoceros', 'rice', 'salmon', 'Sarawakese', 'scissors', 'sea[- ]bass',
        'series', 'Shavese', 'shears', 'sheep', 'siemens', 'species', 'staff', 'swine', 'traffic',
        'trousers', 'trout', 'tuna', 'us', 'Vermontese', 'Wenchowese', 'wheat', 'whiting', 'wildebeest', 'Yengeese'
    );

代码找到这里,这个问题就已经明白了,是因为 update 了 doctrine/inflector 这个包导致的。

Step 6. 继续深究

于是重开一个目录,pull 一下这几个版本发现 1.3.0 开始发生了变化,加入了 feedback 没有复数形式。

composer require doctrine/inflector 1.2.0
composer require doctrine/inflector 1.3.0

继续开新目录 Clone 源代码分析:

git clone https://github.com/doctrine/inflector.git 

查看 git log 看到了这个注释信息 Added more uninflected words

2568817904.png

 

探究到这里,我想这个问题真的明白了。

按照惯例得总结一下结尾:

  1. Model 里面尽量指定一个 $table,有可能把握不准单词复数的形式。
  2. composer update 之后要通过 composer.lock 检查有版本变化的包。
  3. 英文真的很重要。
  4. 源码面前,了无秘密。
  5. 祝阅读到最后的人技术再上一个 level。

Perl

Perl

优化 Perl 代码

坦率地说,我喜欢 Perl。也许是用太久了,有一定的感情,Perl编写了很多管理脚本,并编写了一些游戏。通常使用 Perl 是为了节省时间,并为我自动检查一些服务器信息,甚至使用 Perl 来自动编写邮件WebMail。由于使用 Perl 让一切都变得如此简单,因此很容易忘记对其进行优化。许多情况下,这并不是世界末日。因此多花几个毫秒来查询或处理日志文件又有什么关系呢?

然而,这些相同的懒惰习惯在小程序中可能只是多花费几毫秒的时间,但是在大规模开发项目中,多耗费的时间就变成数倍了。这就是 Perl 的 TMTOWTDI (There's More Than One Way To Do It) 颂歌开始变坏的地方。如果您需要很快的速度,不管有多少种慢速的方法,但是可能只有一两种方法可以达到最快的结果。最终,即使您可以得到预期的结果,但草率的编程还是会导致拙劣的性能。因此,在本文中,我们将介绍一些可以用来取消 Perl 应用程序额外执行周期的关键技术。

优化方法

首先,有必要随时记住 Perl 是一门编译语言程序。您所编写的源代码是转换为执行的字节码时进行编译的。字节码本身就有一个指令范围,所有的指令都是使用高度优化的 C 语言编写的。然而,即使在这些指令中,有些操作仍然可以进行优化,得到相同的结果,但是执行的效率更高。总体来讲,这意味着您要使用逻辑序列与字节码的组合,后者是从前者中生成的,最终会影响性能。某些相似操作之间性能的差距可能非常巨大。现在让我们考虑清单 1 和清单 2 中的代码。这两段代码都是将两个字符串连接为一个字符串,一个是通过普通的连接方法实现,而另外一个是通过生成一个数组并使用 join 方法进行连接。

清单 1. 连接字符串,版本 1
my $string = 'abcdefghijklmnopqrstuvwxyz';
my $concat = '';
foreach my $count (1..999999)
{
    $concat .= $string;
}
清单 2. 连接字符串,版本 2
my $string = 'abcdefghijklmnopqrstuvwxyz';
my @concat;
foreach my $count (1..999999)
{
    push @concat,$string;
}
my $concat = join('',@concat);

执行清单 1 需要 1.765 秒,而执行清单 2 则需要 5.244 秒。这两段代码都生成一个字符串,那么是什么操作耗费了这么多时间呢?传统上讲(包括 Perl 开发组),我们都认为连接字符串是一个非常耗时的过程,因为我们需要为变量扩展内存,然后将字符串及新添加的内容复制到新的变量中。另一方面,向一个数组中添加一个字符串应该非常简单。我们还添加了使用 join() 复制连接字符串的问题,这会额外增加 1 秒的时间。

这种情况下的问题在于,将字符串 push() 到一个字符串中非常耗时;首先,我们要执行一次函数调用(这会涉及压栈和出栈操作),还要添加额外的数组管理工作。相反,连接字符串操作非常简单,只是运行一个操作码,将一个字符串变量附加到一个现有的字符串变量中即可。即使设置数组的大小来减少其他工作的负载(使用 $#concat = 999999),也只能节省 1 秒钟的时间。

上面这个例子是非常极端的一个例子,在使用数组时,速度可以比使用字符串快数倍;如果需要重用一个特定的序列,但要使用不同的次序或不同的空格字符,那么这就是很好的一个例子。当然,如果想重新排列序列的内容,那么数组也非常有用。顺便说一下,在这个例子中,产生一个重复 999,999 次字符的字符串的更简便方法是:

$concat = 999999 x 'abcdefghijklmnopqrstuvwxyz';

这里介绍的很多技术单独使用都不会引起多大的差异,但是当您在应用程序中组合使用这些技术时,就可以在 Perl 应用程序中节省几百毫秒的时间,甚至是几秒的时间。

使用引用

如果使用大型数组或 hash 表,并使用它们作为函数的参数,那么应该使用它们的一个引用,而不应该直接使用它们。通过使用引用,可以告诉函数指向信息的指针。如果不使用引用,就需要将整个数组或 hash 表复制到该函数的调用栈中,然后在函数中再次对其进行复制。引用还可以节省内存(这可以减少足迹和管理的负载),并简化您的编程。

字符串处理

如果在程序中使用了大量的静态字符串,例如,在 Web 应用程序中,那么就要记得使用单引号,而不是使用双引号。双引号会强制 Perl 检查可能插入的信息,这会增加打印字符串的负载:

print 'A string','another string',"\n";

我使用逗号来分隔参数,而不是使用句号将这些字符串连接在一起。这样可以简化处理过程; print 只是简单地向输出文件发送一个参数。连接操作会首先将字符串连接在一起,然后将其作为一个参数打印。

循环

正如您已经看到的一样,带有参数的函数调用的开销很高,因为要想让函数调用正常工作,Perl 只能将这些参数压入调用堆栈之后,再调用函数,然后从堆栈中再次接收响应。所有这些操作都需要尽避免我们不需要的负载和处理操作。由于这个原因,在一个循环中使用太多函数调用不是个好主意。同样,这减少了比较的次数。循环 1,000 次并向函数传递信息会导致调用该函数 1,000 次。要解决这个问题,只需要调整一下代码的顺序即可。我们不使用 清单 3 的格式,而是使用清单 4 中的格式。

清单 3. 循环调用函数
foreach my $item (keys %{$values})
{
    $values->{$item}->{result} = calculate($values->{$item});
}
sub calculate
{
    my ($item) = @_;
    return ($item->{adda}+$item->{addb});
}
清单 4. 函数使用循环
calculate_list($values);
sub calculate_list
{
    my ($list) = @_;
    foreach my $item (keys %{$values})
    {
        $values->{$item}->{result} = ($item->{adda}+$item->{addb});
    }
}

更好的方式是在这种简单的计算中或者在简单的循环中使用 map

map { $values->{$_}->{result} = $values->{$_}->{adda}+$values->{$_}->{addb} } keys %{$values};

还要记住的是,在循环中,每次反复都是在浪费时间,因此不要多次使用相同的循环,而是要尽量在一个循环中执行所有的操作。

排序

另外一种有关循环的通用操作是排序,特别是对 hash 表中的键值进行排序。在这个例子中嵌入对列表元素进行排序的操作是非常诱人的,如清单 5 所示。

清单 5. 不好的排序
my @marksorted = sort {sprintf('%s%s%s',
      $marked_items->{$b}->{'upddate'},
      $marked_items->{$b}->{'updtime'},
      $marked_items->{$a}->{itemid}) <=>
      sprintf('%s%s%s',
            $marked_items->{$a}->{'upddate'},
            $marked_items->{$a}->{'updtime'},
            $marked_items->{$a}->{itemid}) } keys %{$marked_items};

这是一个典型的复杂数据排序操作,在该例中,要对日期、时间和 ID 号进行排序,这是通过将数字连接在一个数字上,然后对其进行数字排序实现的。问题是排序操作要遍历列表元素,并根据比较操作上下移动列表。这是一种类型的排序,但是与我们已经看到的排序的例子不同,它对每次比较操作都调用sprintf。每次循环至少执行两次,遍历列表需要执行的精确循环次数取决于列表最初排序的情况。例如,对于一个 10,000 个元素的列表来说,您可能会调用 sprintf 超过 240,000 次。

解决方案是创建一个包含排序信息的列表,并只生成一次排序域信息。参考清单 5 中的例子,我将这段代码改写为清单 6 的代码。

清单 6. 较好的排序
map { $marked_items->{$_}->{sort} = sprintf('%s%s%s',
      $marked_items->{$_}->{'upddate'},
      $marked_items->{$_}->{'updtime'},
      $marked_items->{$_}->{itemid}) } keys %{$marked_items};
my @marksorted = sort { $marked_items->{$b}->{sort} <=>
      $marked_items->{$a}->{sort} } keys %{$marked_items};

现在不需要每次都调用 sprintf,对 hash 表中的每一项,只需要调用一次该函数,就可以在 hash 表中生成一个排序字段,然后在排序时就可以使用这个排序字段了。排序操作只能访问排序字段的值。您可以将对包含 10,000 个元素的 hash 表的调用从 240,000 次减少到 10,000 次。这取决于最初对排序部分执行的操作,但是如果使用清单 6 中的方法,则可能节省一半的时间。

如果使用从数据库(例如 MySQL 或类似的数据库)查询的结果来构建 hash 表,并在查询中使用使用排序操作,然后按照这个顺序来构建 hash 表,那么就无需再次遍历这些信息来进行排序。

使用简短的逻辑

与排序相关的是如何遍历可选值列表。使用很多 if 语句耗费的时间可能会令人难以置信。例如,请参阅清单 7 中的代码。

清单 7. 进行选择
if ($userchoice > 0)
{
    $realchoice = $userchoice;
}
elsif ($systemchoice > 0)
{
    $realchoice = $systemchoice;
}
else
{
    $realchoice = $defaultchoice;
}

这段代码除了浪费篇幅之外,其结构也有两个问题。首先,从编程的观点来看,它从来不会检查变量值是否有效,如果激活警告信息,这就是要注意的一个因素。其次,只有在到达每个选项时,才会对每个选项进行检查,这是一种浪费,因为比较操作(尤其是对字符串进行比较操作)非常耗时。这两个问题都可以使用简短逻辑进行解决。

如果使用逻辑 || 操作符,Perl 就会使用后面的第一个真值,顺序从左到右取值。一旦发现一个有效值之后,它就不会继续处理其他值。另外,由于 Perl 正在寻找一个真值,因此它也可以忽略未定义的值,而不会抱怨这些值尚未定义。因此您可以将上面的代码重新改写成以下这样:

$realchoice = $userchoice || $systemchoice || $defaultchoice;

如果 $userchoice 值为真,那么 Perl 就不会查看其他变量。如果 $userchoice 为假(请参阅表 1),那么 Perl 就需要检查 $systemchoice 的值,依此类推,直到最后一个值,这个值通常都会被使用,不管它是真还是假。

表 1. $userchoice 值

使用 AutoLoader

在执行 Perl 脚本过程中,最耗时的部分是将源代码编译成可以真正执行的字节码。对于一个没有使用外部模块的小脚本来说,这个过程可能需要几毫秒的时间。而启动一个包括很多外部模块的 Perl 脚本,就会增加时间。原因是 Perl 对模块的操作远远不止导入文本并使用相同的编译步骤运行这么简单。它会将 200 行的脚本快速转换为 10,000 或 20,000 行。结果是您增加了编译过程初始阶段的工作,在这时,脚本还不能执行任何任务。

在正常执行脚本时,执行这些模块中定义的函数可能只需要使用 10% 甚至 5% 的时间。因此为什么不在启动脚本时加载这些模块呢?解决方案是使用 AutoLoader,其作用类似于 Perl 模块的动态加载程序。它使用了 AutoSplit 系统生成的文件,可以将一个模块划分成单个函数。当通过 use 加载模块时,要做的工作是加载该模块的 stub 代码。只有在调用 AutoLoader 加载的模块中包含的函数、然后为该函数加载并编译代码时,才会执行这项操作。结果是,把加载了模块的 20,000 个行的脚本又转换回 200 行的脚本,这将加速最初的加载和编译过程。

只是在程序中使用 AutoLoader 系统来代替预加载,就可以节省 2 秒钟的时间。这种方法很容易使用,只需将清单 8 所示的模块修改为清单 9 的格式,然后使用 AutoSplit 来创建需要的加载函数即可。注意,并不需要再使用 Exporter;AutoLoader 会自动处理加载单个函数的过程,而不用您显式地将其列出。

清单 8. 标准模块
package MyModule;
use OtherModule;
require 'Exporter';
@EXPORT = qw/MySub/;
sub MySub
{
   ...
}
1;
清单 9. 自动加载模块
package MyModule;
use OtherModule;
use AutoLoader 'AUTOLOAD';
1;
__END__
sub MySub
{
   ...
}

这里的主要区别是,您希望自动加载的函数不再是在模块的包中定义,而是在模块末尾的数据段(在__END__ 标志之后)定义。AutoSplit 会将在此处定义的所有函数放到特殊的 AutoLoader 文件中。要分割该模块,需要使用下面的命令行:

perl -e 'use AutoSplit; autosplit($ARGV[0], $ARGV[1], 0, 1, 1)' MyModule.pm auto

使用字节码和编译器后端

使用编译器有三种方法:字节码、完全编译或简单地作为一个调试/优化工具。前两种方法依赖于将原来的 Perl 源代码转换为编译好的字节码格式,并存储预编译的版本,以便以后执行。最好是通过 perlcc命令使用这种方法。这两种模式使用相同的基本模型,但是产生的最终结果不同。在字节码的模式中,最终编译好的字节码被写入另外一个 Perl 脚本中。该脚本由 ByteLoader 前同步码组成,编译好的代码以字节字符串的形式保存。要创建字节码的格式,请在 perlcc 命令中使用 -B 选项。如下所示:

$ perlcc -B script.pl

这会创建一个文件 a.out。然而,输出结果的 Web 界面并不友好。结果文件可以在任何平台上作为 Perl 可执行程序执行(Perl 字节码是与平台无关的):

$ perl a.out

这样做的优点是节省了 Perl 每次将脚本从源代码编译成字节代码的时间。相反,它只运行生成的字节码。这与 Java 编译的过程类似,实际上与任何语言的真正编译步骤相同。在短小的脚本中,特别是哪些使用了很多外部模块的脚本中,您可能不会注意到速度有很大的提升。在大型的没有使用外部模块的“独立”脚本中,您应该会看到速度有明显的提升。

完全编译模式差不多也是这样,除了它会产生一个 Perl 脚本,其中嵌入了一些编译好的字节码。 perlcc会产生一些嵌入 C 代码的版本,然后将其编译成完全独立的可执行代码。虽然这个代码不是跨平台兼容的,但是它允许您分发一个 Perl 的可执行脚本,而不会泄漏出源代码。然而,需要注意的是,它并没有将 Perl 代码转换为 C 代码,只是将 Perl 字节码嵌入一个基于 C 的应用程序中。这实际上是 perlcc 的默认格式,因此,一个简单的 $ perlcc script.pl 会创建并编译一个独立的程序 a.out

更少为人知的一种调试和优化代码的方法是,在 Perl 编译器中使用 "编译器后端"。

实际上是编译器后端驱动了 perlcc 命令,该命令可以使用一个后端模块直接创建一个 C 源代码文件,您可以查看该文件的内容。Perl 编译器通过使用所生成的字节码,以一种不同的方式输出结果。因为您正在查找在编译过程中生成的字节码,所以可以看到代码经过 Perl 自己内部优化后的结果。了解 Perl 的操作码,就可以开始判断哪里可能是瓶颈。从调试的观点来看,可以使用后端,例如 Terse (它自己就是 Concise 中的一个程序)和 Showlex。在清单 10 中,可以看到原来的清单 1 经过 Terse 后端处理后的样子。

清单 10. 使用 Terse 研究字节码
LISTOP (0x306230) leave [1]
    OP (0x305f60) enter
    COP (0x3062d0) nextstate
    BINOP (0x306210) sassign
        SVOP (0x301ab0) const [7] PV (0x1809f9c) "abcdefghijklmnopqrstuvwxyz"
        OP (0x305c30) padsv [1]
    COP (0x305c70) nextstate
    BINOP (0x305c50) sassign
        SVOP (0x306330) const [8] PV (0x180be60) ""
        OP (0x306310) padsv [2]
    COP (0x305f20) nextstate
    BINOP (0x305f00) leaveloop
        LOOP (0x305d10) enteriter [3]
            OP (0x305cf0) null [3]
            UNOP (0x305cd0) null [141]
                OP (0x305e80) pushmark
                SVOP (0x3065d0) const [9] IV (0x180be30) 1
                SVOP (0x3065f0) const [10] IV (0x1801240) 999999
        UNOP (0x305ee0) null
            LOGOP (0x305ec0) and
                OP (0x305d50) iter
                LISTOP (0x305e60) lineseq
                    COP (0x305e10) nextstate
                    BINOP (0x305df0) concat [6]
                        OP (0x305d70) padsv [2]
                        OP (0x305dd0) padsv [1]
                    OP (0x305ea0) unstack
concat1.pl syntax OK

其他工具

我们已经介绍的内容全部都是针对组成应用程序的代码的。虽然这是大部分问题之所在,但是还有一些工具和系统,可以用来帮助您判断和定位代码中最终有助于提高性能的一些问题。

warnings/strict

这是一个常见建议,但它们的确有所不同。使用 warnings 和 strict 标记可以确保不会出现可笑的变量使用、输入错误和其他问题。在脚本中使用这两个标记可以帮助您减少各种问题,其中很多都可能成为性能的瓶颈之源。这些标记引起的常见错误是:不正确的引用和取消引用,使用未定义的值,以及帮助判断输入错误,例如未使用的值或未定义的函数。

然而,所有这些帮助都可能会造成一些性能的损耗。我在编程和调试时保留了 warnings 和 strict,但在真正使用时,去掉了这些内容。这样不会节省太多时间,但能节省几毫秒的时间。

Profiling

Profiling 是一个非常有用的优化代码的工具,但是它的作用是判断问题的位置;它并不能真正指出潜在的问题是什么,以及如何解决这些问题。而且,由于 profiling 依赖于监视应用程序不同部分的多次执行情况,所以有时候,对问题出在哪儿以及问题的解决方法,它会给出一些错误的建议。

然而,profiling 仍然非常有用,通常它都是优化过程的一个关键步骤。不要仅仅依靠它告诉您自己应该知道的所有内容。

调试

对于我来说,一个优化不好的程序就意味着该程序有 bug。反之亦然:bug 通常都会引起性能的问题。典型的例子是错误的取消引用变量,或者读取或过滤错误的信息。不用关心所用的调试技术使用的是 print语句,还是 Perl 提供的完整调试器。越快消除这些 bug,就能越早开始优化程序。

使用所有的技术

现在您已经了解了一些技术,这里有一种方法可以使用这些这些技术生成优化的应用程序。在进行优化时,我通常遵循以下步骤:

  1. 使用上面介绍的技术编写尽可能优化的程序。一旦开始有规律地使用这些技术,它们就会变成您进行编程的惟一方法。
  2. 完成编程之后,或者在可以发布程序时,通读代码,再次手工检查程序,确定您使用的是 可用的最有效解决方法。仅仅通过阅读代码,您就可以发现一些问题,还可能发现一些潜在的 bug。
  3. 调试程序。bug 可能引起性能问题,因此,应该在进行优化之前首先消除 bug。
  4. 运行 profiler。通常我要对重要的应用程序都执行一次这样的操作,只是为了看一下自己是否遗漏了什么内容 —— 显然一般都会遗漏点什么。
  5. 回到步骤 1,重新开始。我忘记统计自己第一次忘记进行优化的次数了。我可能会反复这个过程 2 到 3 次,也可能离开做另外一个项目,然后在几天、几周或几个月后再回来进行优化。几周或几个月之后,您通常会发现实现相同功能而又可以节省时间的其他方法。

在每天日落时,并没有魔法杖可以帮助您优化软件。即使使用调试器和 profiler,您所获得的也只不过是一些简单的信息:什么可能导致了性能问题,以及有关应该如何修复这些问题的一些不太必要同时也不太有用的建议。还要注意的是,您可以优化的内容很有限。有些操作要花费很多时间才能完成。如果必须遍历一个 10,000 元素的 hash 表,那么就没有办法简化这个过程。但是,正如您已经看到的,有一些方法可以减少每种情况的开销。

相关主题

Perl

Perl性能优化的三个技巧

本文和大家重点讨论一下Perl性能优化技巧,利用Perl开发一些服务应用时,有时会遇到Perl性能或资源占用的问题,可以巧用require装载模块,使用系统函数及XS化模块,自写低开销模块等来优化Perl性能。

Perl性能优化

Perl是强大的语言,是强大的工具,也是一道非常有味道的菜:-)利用很多perl的特性,可以实现一些非常有趣而实用的功能。

利用Perl开发一些服务应用时,有时会遇到Perl性能或资源占用的问题,如何解决呢?以下是自己过去开发实践的一些经验,几个主要的技巧分别是:

◆巧用require装载模块
◆使用系统函数及XS化模块
◆自写低开销模块
◆优化正则表达式
◆善用BSDsocket

巧用require装载模块

为避免程序一启动就加载大量模块,降低启动速度,可以在必要的时候再装载模块,这时候就是require大派用场的时候了。如:

#!/usr/bin/perl-w  
usepre_load_module;  
 
#Initializesomething  
init_args();  
 
#if$use_this_moduleistrue,loadtheModule  
if($use_this_module){  
	requireModule;  
}  

上述代码中,如果变量$use_this_module设置了,那么才加载Module,如果没设置则不需要加载,实现了:useondemand的功能。在CGI应用程序中,这相当有用,如果每次请求(fork)都加载大量无用模块的话,响应速度会有所降低,而在特定场合才加载一些模块将加块启动、解析的速度,提高Perl性能。

再看一个例子:

#!/usr/bin/perl  
my$pid=forkordie"can'tfork:$!\n";  
if($pid){  
	print"i'mfather\n";  
	sleep;  
}else{  
	print"i'mchild\n":  
	requireIO::Socket;  
	sleep;  
} 

上述代码中,如果在程序一开始就用use来载入IO::Socket模块,那么子/父进程都加载了该模块,通过top命令发现子父进程大小都是3.07MB;如果只在子进程里加载,则只在子进程里有效,内存的消耗将降低,top命令发现子进程3.04MB,父进程变为1.4MB。

使用系统函数及XS化模块

Perl内建的系统函数及用c编写的perlXS扩展模块的速度和效率都比纯perl的实现要好得多。在Perl性能要求较高的场合(如开发ApplicationServer,NetworkServer等),可以考虑使用这些内建函数或XS化模块。

如Socket就比IO::Socket的内存消耗要低,XS编写的Data::Dumper就比纯Perl的Data::Dumper要快4-5倍。
此外,一些简单的任务并没必要使用Perl模块,如获得主机IP地址就大可不必载入庞大的Net::DNS而只是使用gethostbyname()系统函数即可。

以下是一些常用的替代方案以获得更快的速度,更好的效率:

◆用sys*系列函数等替代open/seek/tell/<>等标准IO操作
◆用Socket代替IO::Socket以获得更低开销和内存占用
◆用get*by*系列函数代替Net::DNS
◆用index/substr等代替部分低效正则表达式
◆用select(3参数版本)代替IO::Handle部分功能.......

自写低开销模块

通常我们使用一些Perl模块时,只使用了其中很小一部分的功能,可是却不得不载入整个模块,甚至要载入其他不相关的模块。因此往往使整个程序非常臃肿庞大。
著名的web管理软件webmin的miniserv(一个简化的http服务端)功能强大,还支持SSL,但资源占用却出奇的少,只有大约5.6MB的大小!这是为什么呢?因为miniserver只使用了2个Perl系统模块(Socket及POSIX),没有载入其他的模块。一些本需要其他perl模块的功能,均由web-lib.pl等用系统函数编写代替。

例如以下是一个获得A记录的高速函数get_mx(),它不依赖任何模块,速度非常快,可以提高Perl性能。

subget_mx{  
	my@info=gethostbynameshift;  
	my@addr=splice(@info,4);  
	my@rt;  
	foreach(@addr){  
		push@rt,join('.',unpack('C4',$_));  
	}  
	\@rt;  
} 

另一个例子,对于标准的IO::Handle对象,可以使用$obj->autoflush(1);来设置缓冲的特性,我们通过使用系统函数select()来获得同样的能力,而无需要载入IO::Handle,代码如下:

subautoflush{  
	my$io=$_[0];  
	select((select($io),$|=1)[0]);  
}