数据库优化往往从索引开始,但Hyperf项目中的索引并不会因为你在模型里定义了字段、在查询构造器里写了where条件就自动生效。索引真实落库依赖迁移文件,也就是通过Schema和Blueprint定义的那套结构变更流程。索引创建看似简单,真正上线时经常遇到索引未命中、联合索引顺序错误、重复创建导致迁移失败、大表加索引引起锁表等问题。本文把Hyperf迁移中创建索引的几种写法和注意事项拆解开,便于你在开发阶段就避开这些坑。

一、Hyperf迁移中创建普通索引和唯一索引
在Hyperf里,如果表还不存在,可以在Schema::create的回调中直接为字段追加索引。创建普通索引用Blueprint的index方法,创建唯一索引用unique方法。它们的第一个参数都是字段名字符串,第二个参数是索引名称。索引名称可以省略,但建议显式指定。
不指定索引名称时,MySQL会根据字段名生成默认名称,单列索引通常与列名相同。这样做在小表里没问题,但如果后续要删除索引、或同一列上存在多个索引,默认命名就容易产生混淆。显式命名还能保证迁移文件在不同环境里行为一致,避免靠猜测名称来写dropIndex。
use Hyperf\Database\Schema\Schema;
use Hyperf\Database\Schema\Blueprint;
use Hyperf\Database\Migrations\Migration;
class CreateUsersTable extends Migration
{
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('username', 50);
$table->string('email', 100);
$table->timestamps();
$table->index('username', 'idx_username');
$table->unique('email', 'uk_email');
});
}
public function down(): void
{
Schema::dropIfExists('users');
}
}
用户名通常用于精确查询或登录,email则要求全局不重复,所以上面分别建了普通索引和唯一索引。这里注意索引名称在MySQL中只需要表内唯一,不需要全库唯一。团队可以统一命名规范,例如单列普通索引用idx_列名,唯一索引用uk_列名,便于从名称直接判断索引类型。
如果只是创建表时同步建索引,down里直接删表就够了,不需要单独dropIndex。但如果是在已有表上追加索引,down必须对称地删除,保证迁移可以回滚。
二、联合索引和联合唯一索引的创建顺序
当一个查询同时过滤多个字段时,单独给每个字段建索引往往不能让MySQL高效执行。比如订单列表经常按user_id和status筛选,再按created_at排序,这时更好的做法是创建(user_id, status, created_at)的联合索引,而不是给三个字段各建一个单列索引。
联合索引遵循最左前缀原则。查询条件中如果不包含最左列user_id,仅用status或created_at过滤,联合索引不会被命中。因此在设计字段顺序时,要把等值条件中区分度高、经常最先出现的列放在前面,范围查询或排序字段靠后放。像上面的场景,user_id是等值条件,status虽然也是等值但可能区分度低,created_at常用于范围或排序,所以可以安排为(user_id, status, created_at)。
Schema::create('orders', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id');
$table->tinyInteger('status');
$table->dateTime('created_at');
$table->index(['user_id', 'status', 'created_at'], 'idx_user_status_created');
});
如果你还需要保证同一用户对某条业务数据唯一,例如一个用户只能有一条默认配置,那么联合唯一索引是合适的。使用unique方法传入数组即可:
$table->unique(['user_id', 'config_type'], 'uk_user_config_type');
另外要注意,如果已经创建了(user_id, status, created_at)联合索引,通常就不需要再给user_id单独建普通索引了,因为联合索引最左列已经能够覆盖只按user_id查询的场景。重复索引不仅浪费写入性能,还会占用额外磁盘空间。是否保留单列索引需要结合实际查询分布判断,而不是照搬网上模板。
三、给已有表添加索引以及避免锁表
开发初期容易在创建表时把索引一次建好,但线上经常会出现表已经运行很久,才发现某个高频查询没有索引。此时需要写一个新的迁移文件,用Schema::table给已有表追加索引,而不是修改旧的创建表迁移。
use Hyperf\Database\Schema\Schema;
use Hyperf\Database\Schema\Blueprint;
use Hyperf\Database\Migrations\Migration;
class AddIndexToOrdersTable extends Migration
{
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->index('order_no', 'idx_order_no');
$table->index(['user_id', 'status'], 'idx_user_status');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropIndex('idx_order_no');
$table->dropIndex('idx_user_status');
});
}
}
给已有表加索引不能只考虑语法,还要考虑表的体积和执行时间。对于数据量较大的表,直接执行ADD INDEX可能锁表并阻塞写入。Hyperf的Blueprint没有直接暴露MySQL的ALGORITHM和LOCK选项,但可以借助DB::statement执行原生SQL,选择INPLACE和NONE来降低锁影响。具体能不能用INPLACE,取决于MySQL版本和索引类型,执行前应在测试环境验证。
ALTER TABLE orders ADD INDEX idx_order_no (order_no) ALGORITHM=INPLACE, LOCK=NONE;
迁移文件如果被重复执行,会因为索引已存在而报错。比较稳妥的做法是在执行前查询information_schema.statistics,判断目标索引是否已经创建,避免重复添加。下面是一个示例:
use Hyperf\DbConnection\Db;
public function up(): void
{
$rows = Db::select(
"SELECT COUNT(*) AS cnt FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'orders' AND index_name = 'idx_order_no'"
);
if ((int)$rows[0]->cnt === 0) {
Db::statement('ALTER TABLE orders ADD INDEX idx_order_no (order_no) ALGORITHM=INPLACE, LOCK=NONE');
}
}
这段代码中的SQL语句使用了英文双引号包裹字符串,在PHP代码里是合法写法。如果需要拼接变量,要使用参数绑定而不是直接拼接,防止SQL注入。对于固定的迁移语句,直接写表名和索引名问题不大,因为迁移文件由开发者控制。
四、迁移执行后的验证与索引效果分析
迁移文件写完后,需要在开发环境执行php bin/hyperf.php migrate命令。如果你的Hyperf应用入口脚本是bin/hyperf.php,那么直接使用这个命令即可。执行成功后,可以通过SHOW INDEX FROM orders查看索引是否建好,重点关注Key_name、Column_name和Non_unique字段,确认联合索引的字段顺序是否与设计一致。
SHOW INDEX FROM orders WHERE Key_name = 'idx_user_status';
索引是否真正被查询命中,需要依赖执行计划。以MySQL为例,可以在SQL前加上EXPLAIN,观察key列是否出现你设计的索引名称,rows列表示预估扫描行数。如果key为NULL,说明当前查询没有用上索引,常见原因包括查询条件对索引列做了函数运算、隐式类型转换、字符集不一致,或者联合索引不满足最左前缀。
EXPLAIN SELECT * FROM orders WHERE user_id = 101 AND status = 1 ORDER BY created_at DESC;
需要特别提醒的是,索引不是越多越好。每个索引都会在INSERT、UPDATE、DELETE时付出维护成本,还会占用磁盘和内存。线上表如果发现多个索引字段重复度很高,可以通过information_schema.statistics结合慢查询日志评估哪些索引已经不再使用。冗余索引会增加优化器选择难度,也可能让写入性能变差。因此建议以查询为核心,按真实业务场景添加索引,而不是给每个字段都建一遍。
五、迁移文件中的命名规范和回滚检查
一个项目里迁移文件会越来越多,索引名称如果不统一,后期维护会很痛苦。团队可以约定单列索引用idx_列名,唯一索引用uk_列名,联合索引用idx_列1_列2形式。名称过长或包含保留字时容易引发问题,因为MySQL对索引名称长度有限制,一般控制在64个字符以内。过长名称还会让报错信息变得不直观。
创建索引时还要注意同步考虑down方法。回滚不是简单删表,而是要把up中新增的索引对称删除。否则一旦需要回滚版本,数据库结构会残留索引,影响后续迁移判断。尤其是使用原生SQL加索引的迁移,更应该在down里写出对应的DROP INDEX语句,并加存在性检查,避免回滚时索引不存在而报错。
public function down(): void
{
$rows = Db::select(
"SELECT COUNT(*) AS cnt FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'orders' AND index_name = 'idx_order_no'"
);
if ((int)$rows[0]->cnt > 0) {
Db::statement('ALTER TABLE orders DROP INDEX idx_order_no');
}
}
总的来说,Hyperf迁移中创建索引的核心并不是死记某个方法,而是理解索引在数据库层面的真实作用,再结合Schema和Blueprint把它准确表达出来。设计阶段想清楚联合索引的顺序,执行阶段做好重复检测和大表锁表控制,验证阶段用SHOW INDEX和EXPLAIN确认效果,就能避免大部分因索引问题导致的性能故障。