分类 软件开发 下的文章

生产环境出现了一个有意思的问题。

用户在设备库中勾选“品牌 X”,快速取消,再重新勾选。页面最后仍显示品牌已选中,列表却变成了全部设备。

从现象看,这是典型的请求竞态:先发出的请求后返回,覆盖了后发请求的结果。但奇怪的是,同一台电脑、同一套代码,账号 A 可以稳定复现,账号 B 却表现正常。

最初我们怀疑两个账号的数据量不同。

实际查询后发现:

  • A 有 6032 条设备;
  • B 有 6318 条设备;
  • 两个账号都恰好有 226 条品牌 X 的设备;
  • 属性数量和重复数据也没有明显异常。

B 的数据甚至比 A 更多。

真正的区别,是数据进入系统的时间:

  • A 的设备主要在 4–5 月导入;
  • B 的设备主要在 8 月导入。

两个索引之间,MySQL 选错了路

设备列表的默认查询类似这样:

SELECT *
FROM product
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 20;

product 表中存在两个相关的单列索引:

KEY idx_user_id (user_id)
KEY idx_created_at (created_at)

面对这条 SQL,MySQL 有两个选择。

第一种是使用 user_id 索引,先找到当前账号的约6000条设备,再按照创建时间排序。

第二种是使用 created_at 索引,直接按照全平台设备的创建时间倒序扫描,再逐条判断设备是否属于当前账号。

MySQL 最终选择了第二种。

这个选择看起来很合理:既然只需要最新的20条数据,那么从 created_at 索引末尾开始读取,找到20条属于当前账号的记录后就可以停止,同时还能避免额外排序。

但这个判断忽略了一个关键问题:不同账号的数据在全局时间索引中的位置并不相同。

同一个执行计划,遇到不同数据分布

账号 B 的设备刚导入不久,距离全平台最新数据很近。

数据库从 created_at 索引末尾开始扫描,很快就能找到 B 的20条设备:

  • 实际扫描约5.5万行;
  • 查询耗时约0.13秒。

账号 A 的设备导入时间较早。

数据库同样从全平台最新的数据开始扫描,但需要先跳过其他账号后来导入的大量设备,才能找到 A 的数据:

  • 实际扫描约37万行;
  • 查询耗时约0.9秒。

这个索引没有失效,MySQL 也没有进行全表扫描。它确实使用了索引,只是选择了一条看似聪明、实际代价很高的访问路径。

这就是索引的负优化。

更值得注意的是,执行计划预估只需要扫描约2000行,实际却扫描了37万行。优化器严重低估了从全局时间索引中找到该账号20条数据的成本,因此选择了错误的索引。

查询时差放大了前端竞态

取消品牌 X 时,页面请求全部设备。对于账号 A,这个查询需要约0.9秒。

重新勾选品牌 X 时,查询范围缩小到该品牌的226条设备,耗时约0.05秒。

于是出现了这样的顺序:

  1. 全量请求先发出;
  2. 品牌请求后发出;
  3. 品牌请求先返回,页面显示品牌数据;
  4. 全量请求最后返回,覆盖了最新结果;
  5. 最终页面仍显示品牌已选中,列表却显示全部设备。

账号 B 的全量查询只需要约0.13秒,两个请求之间的时差较小,因此当前操作速度下不容易触发。

但这并不代表 B 永远不会出现问题。数据库不保证并发请求按照发送顺序完成,只是不同的数据分布让问题在 A 上更容易暴露。

前端竞态是页面错乱的直接原因,而错误的索引选择放大了两个请求之间的时间差。

两个单列索引,不等于一个联合索引

很多人会认为,既然已经有 user_idcreated_at 两个索引,MySQL 就可以同时利用它们完成账号过滤和时间排序。

实际上,它通常只能选择一条主要访问路径:

  • user_id 索引:先过滤账号,再排序;
  • created_at 索引:避免排序,再过滤账号。

即使出现 Index Merge,也不代表 MySQL 可以同时高效完成过滤、排序和 LIMIT

这条查询真正需要的索引是:

KEY idx_user_id_created_at (user_id, created_at)

这个联合索引会按照“账号 → 创建时间”组织数据。

数据库可以先定位当前账号,再直接从该账号最新的设备开始读取20条,不需要扫描其他账号的数据,也不需要额外排序。

两个单列索引解决的是两个独立问题:

(user_id)
(created_at)

联合索引解决的是一条完整的查询路径:

(user_id, created_at)

对于下面这种查询:

WHERE A = ?
ORDER BY B
LIMIT N

两个单列索引 (A)(B),通常无法替代联合索引 (A, B)

不使用时间索引,反而快了几十倍

为了验证问题,我们强制查询使用 user_id 索引。

结果是:

  • 账号 A:约0.9秒降到约0.02秒;
  • 账号 B:约0.13秒降到约0.02秒。

虽然使用 user_id 索引后,需要对约6000条设备额外排序,但这个成本远低于从全局时间索引中扫描数十万条无关数据。

这说明排序本身不一定昂贵。为了避免排序而读取大量无关数据,反而可能是更差的选择。

数据库优化不能代替前端修复

增加 (user_id, created_at) 联合索引,可以让两个请求都快速返回,大幅降低乱序出现的概率。

但查询变快并不能保证请求顺序。

网络波动、数据库负载、连接池等待和缓存命中情况,都可能让后发请求先完成。因此前端仍然需要处理请求竞态。

常见方案有两种:

  • 发起新的筛选请求时,取消上一次尚未完成的请求;
  • 为每次请求分配递增序号,只允许最后一次请求更新页面。

第二种方式的核心逻辑是:

请求 1 发出
请求 2 发出
请求 2 返回并更新页面
请求 1 返回,但发现自己不是最新请求,因此丢弃结果

这样无论数据库以什么顺序返回,旧数据都不会覆盖用户当前的筛选结果。

这次问题留下的提醒

排查 MySQL 性能问题时,不能只看“有没有索引”,也不能看到 EXPLAIN 中出现索引名,就认为查询已经得到优化。

还需要通过 EXPLAIN ANALYZE 关注:

  • 实际扫描了多少行;
  • 预估行数与实际行数是否严重偏离;
  • 索引顺序是否符合完整查询路径;
  • 是否为了避免排序而扫描了大量无关数据;
  • 数据分布变化后,原来的执行计划是否仍然合理。

索引不是简单地给某个字段加速,而是在告诉数据库:数据应该按照什么路径被找到。

路径设计正确,数据库可以直接到达目标;路径设计错误,索引越积极地工作,查询反而可能越慢。

这次问题最终需要同时处理两层:

  • 数据库增加 (user_id, created_at) 联合索引,消除由数据时间分布造成的查询时差;
  • 前端只允许最后一次筛选请求更新页面,彻底避免旧请求覆盖新结果。

Dockerfile

在原来镜像的 Dockerfile 中添加如下定义

RUN pecl install xdebug-3.0.2
RUN docker-php-ext-enable xdebug

RUN { \
        echo 'zend_extension=xdebug.so'; \
        echo 'xdebug.mode=profile'; \
        echo 'xdebug.start_with_request=trigger'; \
        echo 'xdebug.output_dir=/tmp/xdebug'; \
    } > /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini;

重新打包镜像就能得到一个包含了 xdebug 扩展的 php 镜像了,如果不确定是否添加成功,可以在容器运行起来之后,执行docker exec -i container_name php -m | grep xdebug 检查 xdebug 扩展是否安装成功

配置解析

这里我们采用的是 xdebug3,配置与 xdebug2 不太一样,具体区别可以看这里,网上能搜到的大部分都是 xdebug2 的配置。

为了不影响其他请求的性能,也为了减少日志数量,我们采用 start_with_request=trigger,手动触发 xdebug profile

xdebug.mode=profile 表示使用 xdebug 的 profile 模式
xdebug.start_with_request=trigger 表示默认不开启 xdebug profile,需要通过 http 请求中,添加 > XDEBUG_PROFILE=1 参数来开启,GET/POST 均可
xdebug.output_dir 指定 xdebug 日志的存放位置,如果指定的文件夹不存在,是无法生成日志的

由于我们是在 docker 中使用 xdebug,xdebug.output_dir 对应的目录最好从宿主机挂载进去,方便查看

开始分析

构造一个请求,例如:http://127.0.0.1:8080/xdebug?XDEBUG_PROFILE=1

之后可以在上述配置的文件夹中找到文件名类似cachegrind.out.20的文件,就是我们要的 profile 文件了。

Mac 用户可以下载 qcachegrind 来查看 profile 文件 brew install qcachegrind

下载完之后打开文件就能看到图形化的分析结果了。

参考文献

之前面试被问到的一个题目,想起来了就记录下

题目大概是这样:

商店规定 3 个空瓶可以换一瓶饮料,问买 10 瓶饮料可以喝到多少瓶?

提供两种解法

1. 循环

<?php

// 循环实现
function drink($full, $empty = 0) {
    // 3 个空瓶换一瓶饮料
    $exchange = 3;
    $drink = 0;
    while ($full > 0) {
        // 喝
        $drink += $full;    // 喝过的总数增加
        $empty = $empty + $full;    // 空瓶数增加

        // 换
        $full = intval($empty / $exchange);
        $empty = $empty % $exchange;
    }


    // 考虑特殊情况,剩余空瓶数 + 1 如果可以兑换一瓶的话,就还可以跟老板预支一瓶,喝完再把空瓶给他
    if ($empty + 1 == $exchange) {
        $drink++;
    }

    return $drink;
}

var_dump(drink(10));

2. 递归

<?php

// 递归实现
function drink_recursive($full, $empty = 0, $drink = 0) {
    // 3 个空瓶换一瓶饮料
    $exchange = 3;

    // 终止条件 - 没有满瓶 && 空瓶不够兑换
    if ($full < 1 && $empty < $exchange) {
        // 考虑特殊情况,剩余空瓶数 + 1 如果可以兑换一瓶的话,就还可以跟老板预支一瓶,喝完再把空瓶给他
        if ($empty + 1 == $exchange) {
            $drink++;
        }
        return $drink;
    }

    // 空瓶兑换
    if ($empty > 0) {
        $full += intval($empty / $exchange);
        $empty = $empty % $exchange;
    }

    // 喝
    if ($full > 0) {
        $drink += $full;
        $empty += $full;
        $full = 0;
    }

    return drink_recursive($full, $empty, $drink);
}

var_dump(drink_recursive(10));

用 deployer 发布 laravel 项目的最简配置

<?php
namespace Deployer;

require 'recipe/laravel.php';

// Set configurations
set('app_name', 'app_name');    // 应用名称
set('writable_mode', 'chown');
set('writable_use_sudo', true);
set('writable_recursive', true);

set('repository', 'ssh://[email protected]:/xxx.git');  // git 地址,要能从目标机器上访问到

// Configure servers
host('prod')
    ->hostname('host')  // 域名或者 ip
    ->user('user')  // 发布的用户名
    //->identityFile('~/.ssh/id_rsa')   // 公钥
    ->stage('production')
    ->set('deploy_path', '/data0/{{app_name}}/{{stage}}')   // 路径随便修改
    ->set('branch', 'master'); // 要发布的分支

// 加速 composer install
desc('Copy vendor directory optimized the composer install');
task('deploy:copy', function () {
    if (has('previous_release')) {
        run('cp -R {{previous_release}}/vendor {{release_path}}/vendor');
    }
});

desc('Restart php-fpm on success deploy');
task('php-fpm:restart', function () {
    // 这个命令按照实际情况修改
    run('service php7.2-fpm restart');
});

before('deploy:vendors', 'deploy:copy');
after('deploy:symlink', 'php-fpm:restart');

// 如果需要的话开启
// after('php-fpm:restart', 'artisan:horizon:terminate');

1. 先添加 ppa:ondrej/php 源

sudo add-apt-repository ppa:ondrej/php
sudo apt-get update

如果找不到 add-apt-repository 命令则执行

sudo apt-get install software-properties-common

2. 安装 PHP7.1

sudo apt-get install php7.1 php7.1-common
sudo apt-get install php7.1-fpm php7.1-curl php7.1-xml php7.1-zip php7.1-gd php7.1-mysql php7.1-mbstring

3. 验证

php -v

4. 移除旧的 PHP 版本(可选)

sudo apt-get purge php7.0 php7.0-common
.....

参考 https://ayesh.me/Ubuntu-PHP-7.1