Skip to content

MongoDB 索引与性能

概述

MongoDB 索引是提升查询性能的关键机制。没有索引的查询会进行全表扫描(Collection Scan),在数据量大时性能极差。合理设计索引是 MongoDB 性能优化的核心。

ESR 规则

MongoDB 官方推荐索引设计遵循 ESR 原则:

  • Equality — 等值匹配字段放在最前面
  • Sort — 排序字段放在等值字段后面
  • Range — 范围查询字段放在最后

基础概念

索引类型

索引类型说明适用场景
单字段索引单个字段上的索引常用查询条件
复合索引多个字段的组合索引多条件查询
多键索引数组字段上的索引$in$all 查询
文本索引文本搜索索引$text 全文搜索
地理空间索引2d/2dsphere 索引位置查询
哈希索引哈希值索引哈希分片键
TTL 索引带过期时间的索引自动删除过期数据
唯一索引值必须唯一的索引email、username

语法与代码

创建索引

php
<?php
declare(strict_types=1);

use MongoDB\Client;

$client = new Client('mongodb://localhost:27017');
$collection = $client->selectCollection('app_db', 'users');

// 单字段索引
$collection->createIndex(['email' => 1]); // 1=升序, -1=降序
$collection->createIndex(['name' => -1]);

// 复合索引 — ESR 规则
$collection->createIndex(['status' => 1, 'createdAt' => -1]);
// status 等值匹配在前, createdAt 排序在后

// 唯一索引
$collection->createIndex(['email' => 1], ['unique' => true]);
$collection->createIndex(['username' => 1], ['unique' => true, 'sparse' => true]);

// TTL 索引 — 文档在指定秒数后自动删除
$collection->createIndex(['createdAt' => 1], [
    'expireAfterSeconds' => 3600 * 24 * 30, // 30天后删除
]);

// 部分索引 — 只索引满足条件的文档
$collection->createIndex(['status' => 1], [
    'partialFilterExpression' => ['status' => ['$eq' => 'active']],
]);

// 多键索引 — 自动创建(数组字段)
// tags 数组字段自动创建多键索引
$collection->createIndex(['tags' => 1]);

// 文本索引
$collection->createIndex(['title' => 'text', 'content' => 'text'], [
    'weights' => ['title' => 10, 'content' => 5],
    'name' => 'text_search_index',
]);

索引管理

php
<?php
declare(strict_types=1);

use MongoDB\Client;

$client = new Client('mongodb://localhost:27017');
$collection = $client->selectCollection('app_db', 'users');

// 列出所有索引
$indexes = $collection->listIndexes();
foreach ($indexes as $index) {
    echo "索引名: {$index['name']}\n";
    echo "键: " . json_encode($index['key']) . "\n";
    echo "唯一: " . ($index['unique'] ? '是' : '否') . "\n";
    echo "\n";
}

// 删除索引
$collection->dropIndex('email_1');         // 按名称删除
$collection->dropIndex(['email' => 1]);  // 按键规范删除
$collection->dropIndexes();               // 删除所有索引(除 _id)

// 查看索引大小
$collection->aggregate([
    ['$indexStats' => []],
]);

实战示例

使用 explain 分析查询

php
<?php
declare(strict_types=1);

use MongoDB\Client;

$client = new Client('mongodb://localhost:27017');
$collection = $client->selectCollection('app_db', 'orders');

// explain — 分析查询执行计划
$query = ['status' => 'completed', 'amount' => ['$gte' => 100]];
$sort = ['createdAt' => -1];

$explain = $collection->find($query, ['sort' => $sort])->explain();

// 解读 explain 结果
$stage = $explain->queryPlanner->winningPlan;

if (isset($stage->stage) && $stage->stage === 'COLLSCAN') {
    echo "全表扫描 — 需要添加索引!\n";
} elseif (isset($stage->stage) && $stage->stage === 'IXSCAN') {
    $indexName = $stage->indexName ?? 'unknown';
    echo "使用了索引: {$indexName}\n";
}

// 检查是否使用了覆盖查询(Covered Query)
// 覆盖查询: 索引包含了查询需要的所有字段,无需回表
$explain = $collection->find(
    ['status' => 'active'],
    ['projection' => ['status' => 1, 'createdAt' => 1, '_id' => 0]]
)->explain();

// executionStats 包含详细执行统计
$explainWithStats = $collection->find($query)
    ->explain(MongoDB\Operation\Explain::VERBOSITY_ALL);

$stats = $explainWithStats->executionStats;
echo "执行时间: {$stats->executionTimeMillis}ms\n";
echo "扫描文档: {$stats->totalDocsExamined}\n";
echo "返回文档: {$stats->nReturned}\n";

慢查询分析

php
<?php
declare(strict_types=1);

use MongoDB\Client;

class SlowQueryAnalyzer
{
    private MongoDB\Driver\Database $database;

    public function __construct(Client $client)
    {
        $this->database = $client->selectDatabase('admin');
    }

    /**
     * 获取慢查询日志
     */
    public function getSlowQueries(int $thresholdMs = 100): array
    {
        $result = $this->database->command([
            'getLog' => 'slow',
        ]);

        $slowQueries = [];
        foreach ($result->toArray()[0]['log'] ?? [] as $entry) {
            if (($entry['millis'] ?? 0) > $thresholdMs) {
                $slowQueries[] = [
                    'operation' => $entry['op'] ?? 'unknown',
                    'namespace' => $entry['ns'] ?? '',
                    'millis'    => $entry['millis'],
                    'query'     => json_encode($entry['query'] ?? []),
                    'timestamp' => $entry['ts'] ?? '',
                ];
            }
        }

        return $slowQueries;
    }

    /**
     * 分析集合的索引使用情况
     */
    public function analyzeCollection(string $db, string $collection): array
    {
        $result = $this->database->command([
            'aggregate' => $collection,
            'pipeline'  => [['$indexStats' => []]],
            'cursor'    => new stdClass(),
        ], ['database' => $db]);

        return $result->toArray();
    }
}

索引策略管理类

php
<?php
declare(strict_types=1);

use MongoDB\Client;

class IndexManager
{
    private MongoDB\Driver\Collection $collection;
    private array $requiredIndexes = [];

    public function __construct(
        Client $client,
        string $dbName,
        string $collectionName,
        array $requiredIndexes = []
    ) {
        $this->collection = $client->selectCollection($dbName, $collectionName);
        $this->requiredIndexes = $requiredIndexes;
    }

    /**
     * 同步索引 — 创建缺少的索引
     */
    public function syncIndexes(): array
    {
        $existingIndexes = $this->getExistingIndexMap();
        $created = [];

        foreach ($this->requiredIndexes as $indexDef) {
            $keyJson = json_encode($indexDef['key']);
            $indexName = $indexDef['name'] ?? $this->generateIndexName($indexDef['key']);

            if (!isset($existingIndexes[$keyJson])) {
                $options = $indexDef['options'] ?? [];
                if (!isset($options['name'])) {
                    $options['name'] = $indexName;
                }

                $this->collection->createIndex($indexDef['key'], $options);
                $created[] = $indexName;
                echo "创建索引: {$indexName}\n";
            }
        }

        return $created;
    }

    /**
     * 创建索引(后台创建,不阻塞)
     */
    public function createInBackground(array $keys, array $options = []): string
    {
        $options['background'] = true;
        $result = $this->collection->createIndex($keys, $options);
        return $result;
    }

    private function getExistingIndexMap(): array
    {
        $map = [];
        foreach ($this->collection->listIndexes() as $index) {
            $map[json_encode((array) $index['key'])] = $index['name'];
        }
        return $map;
    }

    private function generateIndexName(array $keys): string
    {
        $parts = [];
        foreach ($keys as $field => $direction) {
            $parts[] = $field . '_' . $direction;
        }
        return implode('_', $parts);
    }
}

// 使用示例
$indexManager = new IndexManager($client, 'app_db', 'users', [
    ['key' => ['email' => 1], 'options' => ['unique' => true, 'name' => 'idx_email_unique']],
    ['key' => ['status' => 1, 'createdAt' => -1], 'options' => ['name' => 'idx_status_created']],
    ['key' => ['name' => 1], 'options' => ['name' => 'idx_name']],
    ['key' => ['createdAt' => 1], 'options' => ['expireAfterSeconds' => 2592000, 'name' => 'idx_created_ttl']],
]);

$indexManager->syncIndexes();

注意事项

索引的代价

php
<?php
// 索引带来查询性能提升,但也有代价:

// 1. 写入开销 — 每次插入/更新/删除都需要更新索引
//    索引越多,写入越慢
//    建议: 单个集合索引不超过 5-6 个

// 2. 内存占用 — 索引存储在内存中
//    大索引会占用大量 RAM
//    使用 $indexStats 检查索引大小和使用频率

// 3. 磁盘空间 — 索引文件占据额外磁盘空间
//    $collStats 可以查看索引大小

// 4. 创建耗时 — 大集合创建索引需要时间
//    生产环境使用 background: true 后台创建

生产环境创建索引

在生产环境创建索引时,使用 background: true 选项避免阻塞其他操作。对于大集合,考虑在维护窗口期间创建。

最佳实践

1. 复合索引设计

php
<?php
// ESR 规则示例
// 查询: db.users.find({status: 'active', age: {$gt: 18}}).sort({createdAt: -1})

// 最佳索引设计:
$collection->createIndex([
    'status' => 1,     // E: 等值条件在前
    'createdAt' => -1, // S: 排序在中
    'age' => 1,         // R: 范围条件在后
]);

// 反面教材:
// 错误 — 范围字段在排序字段之前
$collection->createIndex(['status' => 1, 'age' => 1, 'createdAt' => -1]);
// 这会导致排序无法使用索引

2. 查询优化清单

php
<?php
// 1. 所有查询条件字段是否有索引?
// 2. 是否存在全表扫描?使用 explain 检查
// 3. 排序字段是否在索引中?
// 4. 是否可以使用覆盖查询?
// 5. $or 查询每个分支是否都有索引?
// 6. 投影是否排除了不需要的字段?
// 7. 是否正确使用了 limit/skip 分页?
// 8. 大数据集是否使用了 allowDiskUse?

3. 分页优化

php
<?php
// 方式1: skip + limit(小数据量)
$cursor = $collection->find([], ['skip' => 100, 'limit' => 20, 'sort' => ['_id' => 1]]);

// 方式2: 基于游标的分页(大数据量,推荐)
$lastId = null;
$pageSize = 20;

if ($lastId) {
    $cursor = $collection->find(
        ['_id' => ['$gt' => new ObjectId($lastId)]],
        ['limit' => $pageSize, 'sort' => ['_id' => 1]]
    );
} else {
    $cursor = $collection->find(
        [],
        ['limit' => $pageSize, 'sort' => ['_id' => 1]]
    );
}

进阶用法

调试与测试技巧

php
<?php
declare(strict_types=1);

// 单元测试辅助函数
function createTestResource(): mixed
{
    return match (true) {
        default => new stdClass(),
    };
}

// 调试输出函数
function debugOutput(mixed , string  = ''): void
{
     =  ? ": " : '';
     .= print_r(, true);
    fwrite(STDERR,  . "\n");
}

// 性能基准测试
function benchmark(callable , int  = 1000): float
{
     = hrtime(true);
    for ($i = 0; $i < $iterations; $i++) {
        $fn();
    }
    return (hrtime(true) - $start) / 1e9;
}

日志记录实践

php
<?php
declare(strict_types=1);

/**
 * 简易日志记录器
 */
class SimpleLogger
{
    private string $logFile;
    private string $level = 'INFO';

    public function __construct(string $logFile)
    {
        $this->logFile = $logFile;
    }

    public function info(string $message, array $context = []): void
    {
        $this->log('INFO', $message, $context);
    }

    public function warning(string $message, array $context = []): void
    {
        $this->log('WARNING', $message, $context);
    }

    public function error(string $message, array $context = []): void
    {
        $this->log('ERROR', $message, $context);
    }

    private function log(string $level, string $message, array $context): void
    {
        $timestamp = date('Y-m-d H:i:s');
        $contextStr = $context ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
        $line = "[{$timestamp}] [{$level}] {$message}{$contextStr}\n";
        file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
    }
}

配置与环境检测

php
<?php
declare(strict_types=1);

// 环境检测工具
class EnvironmentChecker
{
    public static function checkRequirements(array $requirements): array
    {
        $results = [];
        foreach ($requirements as $name => $check) {
            $results[$name] = is_callable($check) ? $check() : false;
        }
        return $results;
    }

    public static function getSystemInfo(): array
    {
        return [
            'php_version' => PHP_VERSION,
            'os' => PHP_OS,
            'sapi' => PHP_SAPI,
            'memory_limit' => ini_get('memory_limit'),
            'max_execution_time' => ini_get('max_execution_time'),
            'loaded_extensions' => get_loaded_extensions(),
        ];
    }
}

常见问题排查

问题可能原因解决方案
连接超时网络问题/配置错误检查配置,增加超时时间
权限不足文件/目录权限使用 chmod/chown 修正
性能下降索引缺失/数据量大添加索引,优化查询
数据不一致并发冲突/事务残留使用锁机制和事务
内存溢出大数据集/未释放资源增大内存限制,分批处理

故障排除步骤

  1. 检查错误日志和异常信息
  2. 确认配置和环境是否正确
  3. 使用调试工具逐步排查
  4. 参考官方文档查找已知问题

版本兼容性说明

功能最低版本说明
基础功能PHP 8.1本文档基准版本
只读属性PHP 8.1public readonly 修饰符
枚举类型PHP 8.1enum 类型和 match 表达式
FiberPHP 8.1协程/轻量级并发
命名参数PHP 8.0foo(arg_name: value)
联合类型PHP 8.0`int
Null 安全运算符PHP 8.0$obj?->method()
析构器 promotionPHP 8.0__construct(public $x)
php
<?php
declare(strict_types=1);

// 版本兼容性检测
function ensureVersion(string $minVersion): void
{
    if (version_compare(PHP_VERSION, $minVersion, '<')) {
        throw new RuntimeException(
            sprintf('需要 PHP %s+, 当前版本: %s', $minVersion, PHP_VERSION)
        );
    }
}

ensureVersion('8.1.0');

参考链接