读取大量数据批量更新

1. 先看结果对比

  • 1.1 测试数据

测试租户:znfangcadmin

经纪人数据量:199003

做的事情:將经纪人表的mobile_tel_encrypted更新为mobile_tel加密后的字符串,原本mobile_tel_encrypted字段为空。

  • 1.2 优化前

CPU使用率和内存使用率:

image.png

可以看到CPU实用率基本在60%徘徊,内存是在11%徘徊。

耗时:更新工具记录上一次是执行时间:


image.png
  • 1.3优化后

image.png

可以看到CPU实用率基本在16%徘徊,内存是在0.3徘徊。

耗时:测过3次,基本是3分钟左右


image.png

2. 优化思路

首先优化前用的是yii2框架的each/batch获取数据,每次拿到100条数据后就处理更新(更新在foreach里面完成,直接是赋值后$model->save()),看源码用的是pdo的fetch方法,正常的话,这个获取数据是用游标的方式,但是执行测试过程中,发现cpu和内存居高不下。并且在中途还会报php内存用完的错:
Allowed memory size of 1073741824 bytes exhausted (tried to allocate 82 bytes)
太变态了,1g都用完了。。。。什么代码呀,说好的batch能节省内存的呢?这里头肯定有哪个地方出差错了。刚开始觉得有可能是php版本太低(现在公司是php5.6 历史原因,不会轻易升级的),后来记起来前公司同事之前也试过处理大量权限代码的,前公司用的是php7也是报内存耗尽。

下午要发版本,不管了,先用mysqli的MYSQLI_USE_RESULT,这个之前试过,可行的。

于是优化主要方向:

  1. 将pdo的fetch查询改完mysqli的query(并采用MYSQLI_USE_RESULT);
  2. 一条一条更新改完批量更新。

于是优化后的代码:
这是自己写的mysqli的一个trait

<?php
/**
 * Created by PhpStorm.
 * User: zhengxj
 * Date: 2018/12/24
 * Time: 15:23
 */

namespace common\traits;

trait MysqliTrait
{

    /**
     * 解析租户库连接字符串,并创建数据库连接对象返回
     * @return \mysqli
     */
    private function getOrgDbConn(){
        $result = $dbConnection; //你是数据库连接
        //自己定义读写超时常量
        if (!defined('MYSQL_OPT_READ_TIMEOUT')) {
            define('MYSQL_OPT_READ_TIMEOUT',  11);
        }
        if (!defined('MYSQL_OPT_WRITE_TIMEOUT')) {
            define('MYSQL_OPT_WRITE_TIMEOUT', 12);
        }
        $mysqli = mysqli_init();
        $mysqli->options(MYSQL_OPT_READ_TIMEOUT, 10000);
        $mysqli->options(MYSQL_OPT_WRITE_TIMEOUT, 10000);

        //连接数据库
        $port = isset($result['port'])?$result['port']:null;
        $mysqli->real_connect($result['server'], $result['uid'], $result['pwd'],$result['database'],$port);
        if ($mysqli->connect_errno) {
            return null;
        }
        $mysqli->set_charset("utf8");
        return $mysqli;
    }

    /**
     * 对每一行进行处理的函数,可被重写
     * @param $row
     * @return array
     */
    public function dealRow(&$row)
    {
        return $row;
    }

    /**
     * 从mysql服务器读取大量数据并处理数据
     * @param string $sql 执行的sql
     * @param int $limit 每次处理多少条数据
     * @param callable $handleFunction 处理方法
     * @throws \Exception
     * @return int
     */
    public function handleData($sql, $limit = 500, callable $handleFunction)
    {
        $db = $this->getOrgDbConn();
        $total = 0;
        try {
            $records = [];
            //读取大量的数据时使用 MYSQLI_USE_RESULT
            if ($result = $db->query($sql,MYSQLI_USE_RESULT)){
                while ($row = $result->fetch_assoc()) {
                    $total ++;
                    $records[] = $this->dealRow($row);
                    if(count($records) >= $limit){
                        call_user_func_array($handleFunction, [$records]);
                        unset($records);
                        $records = [];
                    }
                }
                if(count($records)> 0){
                    call_user_func_array($handleFunction, [$records]);
                }
                $result->free();
            }else{
                echo "mysql 查询失败:errno:".$db->errno.",error:".$db->error;
            }
            return $total;
        }  catch(\Exception $e){
            $db->close();
            throw $e;
        }
    }
}

controller代码

use MysqliTrait; //使用trait
public function actionTest($action, $mobiles='')
{
            $sql = 'SELECT id,mobile,mobile_encrypted FROM `broker`';
            if($action == 'part') {
                $sql .= ' WHERE mobile != "" and mobile_encrypted  = ""';
            } elseif ($action == 'mobile') {
                if(empty($mobiles)) {
                    die('Error mobiles !');
                }
                $mobilesStr = '("'. implode(',"', $mobiles) .'")';
                $sql .= ' WHERE mobile IN '.$mobilesStr;
            }
            echo '开始处理时间: ' . date('Y-m-d H:i:s', time()) . PHP_EOL;
            $db = BrokerEntity::getDb();
            $logger = $this->logger;
            //回调函数
            $function = function ($updateData) use ($db, $logger) {
                if(empty($updateData)) {
                    return;
                }
                $updateSql = 'Update `broker` set `mobile_encrypted` =  CASE `id`';
                foreach ($updateData as $item) {
                    $updateSql .= " WHEN '{$item['id']}' THEN '{$item['mobile_encrypted']}'";
                }
                $updateSql .= ' END WHERE `id` IN ("'.implode('","', array_column($updateData, 'id')).'")';
                try {
                    $db->createCommand($updateSql)->execute();
                } catch (\Exception $e) {
                    $logger->error('update error:'.$e->getMessage());
                }
            };
            $total = $this->handleData($sql, 1000, $function); //此方法就是用trait的handleData代码
            echo '完成处理时间: ' . date('Y-m-d H:i:s', time()) . PHP_EOL;
}
  /**
     * 对每一行进行处理的函数,可被重写
     * @param $row
     * @return array
     */
    public function dealRow(&$row)
    {
        $row['mobile_encrypted'] = TelSecurity::encrypt($row['mobile']);
        return $row;
    }

20181225更新
昨天完成了优化,今天有点空,研究一下为什么yii2的each/batch没有作用。
在网上查了很久,发现这个:

image.png

直接打开连接:
http://php.net/manual/en/mysqlinfo.concepts.buffering.php

  • 主要解释:

Buffered and Unbuffered queries

Queries are using the buffered mode by default. This means that query results are immediately transferred from the MySQL Server to PHP and then are kept in the memory of the PHP process. This allows additional operations like counting the number of rows, and moving (seeking) the current result pointer. It also allows issuing further queries on the same connection while working on the result set. The downside of the buffered mode is that larger result sets might require quite a lot memory. The memory will be kept occupied till all references to the result set are unset or the result set was explicitly freed, which will automatically happen during request end the latest. The terminology "store result" is also used for buffered mode, as the whole result set is stored at once.

Note:

When using libmysqlclient as library PHP's memory limit won't count the memory used for result sets unless the data is fetched into PHP variables. With mysqlnd the memory accounted for will include the full result set.

Unbuffered MySQL queries execute the query and then return a resource while the data is still waiting on the MySQL server for being fetched. This uses less memory on the PHP-side, but can increase the load on the server. Unless the full result set was fetched from the server no further queries can be sent over the same connection. Unbuffered queries can also be referred to as "use result".

Following these characteristics buffered queries should be used in cases where you expect only a limited result set or need to know the amount of returned rows before reading all rows. Unbuffered mode should be used when you expect larger results.

Because buffered queries are the default, the examples below will demonstrate how to execute unbuffered queries with each API.

我的理解是:默认情况下,查询是以缓存模式进行,这意味着mysql服务器查询的数据返回后会存储在php的内存中。如果查询大数据,就要求给php分配的内存必须足够大。

缓存模式适用于读取一个有限集合,或者在读取所有行之前先读取一部分数据。而大批量数据则得用到非缓存模式。

下面是mysqli和pdo的例子:

  • mysqli (之前优化采取的方式就是用这个)
<?php
$mysqli  = new mysqli("localhost", "my_user", "my_password", "world");
$uresult = $mysqli->query("SELECT Name FROM City", MYSQLI_USE_RESULT);

if ($uresult) {
   while ($row = $uresult->fetch_assoc()) {
       echo $row['Name'] . PHP_EOL;
   }
}
$uresult->close();
?>
  • pdo
$pdo = new PDO("mysql:host=localhost;dbname=world", 'my_user', 'my_pass');
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);

$uresult = $pdo->query("SELECT Name FROM City");
if ($uresult) {
   while ($row = $uresult->fetch(PDO::FETCH_ASSOC)) {
       echo $row['Name'] . PHP_EOL;
   }
}

我马上试了一下,测试代码:

    /**
     * 初始化经纪人加密手机号
     * php yii qdgj/paas-accounts/test-mobile (part/init/mobile) (18812342234,18812343234)  --orgcode=fangzhiadmin_test
     * @param string $action
     * @param string $mobiles 多个,隔开
     * @return mixed
     */
    public function actionTestMobile($action = 'part', $mobiles = '')
    {
        if(!in_array($action, ['part','init','mobile'])) {
            die('Error Params !');
        }
        try {
            /** @var Connection $connection */
            $connection = BrokerEntity::getDb();
            $pdo = new \PDO($connection->dsn, $connection->username, $connection->password);
            $pdo->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);

            $uresult = $pdo->query('SELECT b_regbrokerId,mobile_tel,capacity_des,mobile_tel_encrypted FROM `b_regbroker` WHERE mobile_tel != "" and mobile_tel_encrypted = ""');
            if ($uresult) {
                while ($row = $uresult->fetch(\PDO::FETCH_ASSOC)) {
                    print_r($row);
                }
            }
        } catch (\Exception $e) {
            var_dump($e->getMessage());
        }
        return true;
    }

cpu和内存使用率果然降下来了:

$ ps aux | grep test-mobile
www      18847  7.4  0.2 336084 23180 pts/1    S+   11:44   0:01 php yii qdgj/paas-accounts/test-mobile part --orgcode=znfangcadmin

回到正题,yii2的each/batch为啥无效,因为人家用的pdo默认都是用缓存模式,代码又没有设置这个模式,当然就没有用了,如果要生效,还必须自己设置那个pdo模式。那是否自己设置了那个模式就可以呢?很遗憾,我试了一下,不可以,因为yii源码里面,设置模式是在fetch和fetchAll的上层,然而fetchAll是必须是缓存模式,不然会出错,而yii用的过程中,肯定会用到fetchAll的,比如取一个表的所有字段。简言之,如果直接设置了fetch为非缓存模式,那么所有用到fetchAll的都会报错。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容

  • pdo类PDO是一个“数据库访问抽象层”,作用是统一各种数据库的访问接口,与mysql和mysqli的函数库相比,...
    桖辶殇阅读 859评论 0 0
  • Lua 5.1 参考手册 by Roberto Ierusalimschy, Luiz Henrique de F...
    苏黎九歌阅读 13,766评论 0 38
  • pyspark.sql模块 模块上下文 Spark SQL和DataFrames的重要类: pyspark.sql...
    mpro阅读 9,448评论 0 13
  • 如今随着互联网的发展,数据的量级也是撑指数的增长,从GB到TB到PB。对数据的各种操作也是愈加的困难,传统的关系性...
    CaesarXia阅读 11,826评论 1 30
  • 上个星期我们小组完成了一个连接数据库的小页面,并且添加了增删改的功能。 第一个是要连库。下面是我连库的代码,其中我...
    谙逸binz阅读 384评论 1 2