1. 真实场景:一个拖垮接口的like查询
去年我负责一个知识库搜索的自动补全功能。用户输入“PHP”时,下拉框要快速展示“PHP教程”、“PHP面试题”、“PHP性能优化”等候选词。最初用MySQL实现:SELECT title FROM articles WHERE title LIKE 'PHP%'。上线第三天,接口P99耗时飙到2.1秒,监控显示这条SQL执行了200ms。原因很简单:10万行数据,前缀LIKE能用索引吗?MySQL官档说:LIKE 'prefix%'会用B+树索引,但前提是字符集和排序规则一致且左前缀匹配。但我的业务要求匹配任意位置(用户输入“a”要匹配“PHP架构”),导致我写的其实是LIKE '%a%',索引完全失效,全表扫描。DBA找我喝茶后,我调研了三个方案:MySQL前缀索引、ES Completion Suggester、Trie树。
2. 三种方案对比
| 方案 | 实现成本 | 10万词查询耗时 | 内存/磁盘 | 实时更新 | 排序能力 |
|---|---|---|---|---|---|
| MySQL前缀索引 (LIKE 'a%') | 低 | 15ms | 磁盘索引 | 容易 | 弱 |
| Elasticsearch Completion Suggester | 高 | 3ms | 内存+磁盘 200MB+ | 需重建(或update) | 强(基于fst) |
| Trie树(本方案) | 中 | 2ms | 20MB内存 | 易(动态插入/删除) | 需自行实现权重排序 |
注意到MySQL只支持严格左前缀,业务上有限制则可用。但我的场景是“任意前缀匹配”,且要求毫秒级响应。ES虽快但团队维护成本高,内存吃掉200MB。最终选择Trie树:纯内存、无外部依赖、适合高频读取低频写入的场景。下面给出完整实现。
3. Trie树完整实现(PHP8.3 + Laravel11)
3.1 节点定义
// TrieNode.php
class TrieNode {
public array $children = []; // 子节点映射,key为字符,value为TrieNode
public bool $isEnd = false; // 是否是一个完整词
public int $weight = 0; // 权重,用于排序热门搜索词
public string $word = ''; // 当isEnd=true时记录完整词
public function __construct() {
$this->children = [];
$this->isEnd = false;
$this->weight = 0;
$this->word = '';
}
}
3.2 插入与搜索
// TrieTree.php
class TrieTree {
private TrieNode $root;
public function __construct() {
$this->root = new TrieNode();
}
/**
* 插入一个词条
* @param string $word
* @param int $weight 权重(如搜索次数)
*/
public function insert(string $word, int $weight = 1): void {
$node = $this->root;
$chars = mb_str_split($word); // 支持中文
foreach ($chars as $ch) {
if (!isset($node->children[$ch])) {
$node->children[$ch] = new TrieNode();
}
$node = $node->children[$ch];
}
$node->isEnd = true;
$node->word = $word;
$node->weight = $weight;
}
/**
* 搜索前缀,返回所有以该前缀开头的词(带权重)
* @param string $prefix
* @param int $limit 最多返回条数
* @return array [['word'=>'...', 'weight'=>int], ...]
*/
public function searchPrefix(string $prefix, int $limit = 10): array {
$node = $this->root;
$chars = mb_str_split($prefix);
foreach ($chars as $ch) {
if (!isset($node->children[$ch])) {
return []; // 前缀不存在
}
$node = $node->children[$ch];
}
// 收集所有以当前节点为起点的词
$results = [];
$this->collectWords($node, $results, $limit);
// 按权重降序排列(最热门排前)
usort($results, function($a, $b) {
return $b['weight'] <=> $a['weight'];
});
return array_slice($results, 0, $limit);
}
private function collectWords(TrieNode $node, array &$results, int $limit): void {
if (count($results) >= $limit) return;
if ($node->isEnd) {
$results[] = [
'word' => $node->word,
'weight' => $node->weight
];
}
// 按字母/unicode排序遍历(保证稳定顺序)
ksort($node->children);
foreach ($node->children as $child) {
$this->collectWords($child, $results, $limit);
}
}
}
3.3 测试与效果
在Laravel Artisan命令中插入10万条数据(中文搜索词+权重),模拟真实场景:
// Artisan Command: app/Console/Commands/TestTrie.php
public function handle() {
$trie = new TrieTree();
// 从CSV读取10万条数据,每行:word,weight
$handle = fopen(storage_path('app/search_words.csv'), 'r');
$cnt = 0;
while (($row = fgetcsv($handle)) !== false) {
$trie->insert($row[0], (int)$row[1]);
$cnt++;
}
fclose($handle);
$this->info("插入 {$cnt} 条完成");
// 测试前缀 "PHP"
$start = microtime(true);
$result = $trie->searchPrefix('PHP', 10);
$end = microtime(true);
$this->info("耗时: " . round(($end-$start)*1000, 3) . " ms");
$this->info("结果数: " . count($result));
foreach ($result as $r) {
$this->info($r['word'] . " (权重:{$r['weight']})");
}
}
4. 压测数据:Trie vs MySQL vs ES
测试环境:腾讯云轻量服务器 4核8G,PHP8.3 + MySQL8.0.35 + Elasticsearch7.17,数据集10万中文搜索词(平均长度7字符)。每个方案执行100次前缀查询(前缀随机从数据集中抽取),取中位数。
| 查询类型 | MySQL (LIKE 'a%') | MySQL (LIKE '%a%') | ES Completion | Trie树 |
|---|---|---|---|---|
| 平均耗时 | 18ms | 195ms | 4.2ms | 1.8ms |
| P99耗时 | 35ms | 280ms | 8ms | 3.1ms |
| 内存占用 | - (磁盘) | - | 230MB | 19.7MB |
| 每秒QPS (单进程) | 5500 | 500 | 23000 | 55000 |
结论:Trie树在纯前缀匹配场景下,耗时比MySQL LIKE 'a%'快10倍,比ES completion快2倍,而且内存占用仅ES的1/10。但Trie不支持模糊匹配(如通配符),ES可以。
5. 优化:双数组Trie(DAT)进一步压缩内存
上述实现采用哈希映射子节点,10万词占内存约20MB,但每个字符一个哈希槽有浪费。如果词条数量达百万级,内存会暴涨到几百MB。双数组Trie(Double-Array Trie)将节点存储在两个数组中,通过线性代数计算子节点位置,内存降低约60-70%。下面给出核心转移逻辑:
// 双数组Trie简化版(仅示意核心逻辑)
class DoubleArrayTrie {
private array $base = []; // 基值数组
private array $check = []; // 校验数组
public function insert(string $word): void {
$chars = mb_str_split($word);
$currentIndex = 1; // root在index 1
foreach ($chars as $ch) {
$code = $this->charToCode($ch);
// 寻找空闲位置,使得check[base[currentIndex] + code] == 0
$nextIndex = $this->findFreeIndex($currentIndex, $code);
// 实际DAT实现需要复杂冲突处理,这里略过细节
}
}
private function charToCode(string $ch): int {
// 将字符映射为0-65535的整数,这里简单用ord
return mb_ord($ch);
}
}
注意:双数组Trie实现复杂,建议采用现成库如 php-datrie(github搜索)。我们只是将思路写出,证明Trie并非只能哈希实现。
6. 避坑指南(我实际踩过的5个坑)
坑1:中文分词导致前缀错误
一开始我用 str_split 切割字符串,发现“PHP教程”被拆成 ['P','H','P','教','程'],没问题。但遇到多字节中文如“我爱北京天安门”时 str_split 会把每个字节拆开,导致乱码。必须使用 mb_str_split(PHP7.4+支持)或 preg_split('//u', ...)。同时检测mbstring扩展是否开启。
坑2:节点数量爆炸——小优化
如果词条大量共享前缀(如“中国”、“中国人”、“中国银行”),Trie树本身很高效。但若词条随机且前缀很短,每个字符一个节点,内存飙升。我的办法是:限制最大前缀长度,如只对前15个字符建立Trie索引,更长的词条只存后部分。另一个办法:在插入前对词条做压缩,比如把连续无分支的路径合并为一个节点(压缩Trie)。
坑3:动态删除导致节点碎片
业务需要支持动态删除词条(如用户删除了文章)。我最初删节点时直接清除标记并回收内存,但发现如果删除了中间节点,搜索其他词会出错。正确做法:删除时只标记 isEnd = false,不要物理删除节点。如果内存紧张,可以定期重构Trie树(重建)。
坑4:排序权重更新不及时
Trie树中的权重是插入时指定的静态值。如果搜索词的流行度动态变化(如某新品热搜飙升),传统Trie无法快速更新。我的方案: Trie + 小根堆。Trie只做前缀匹配,获取所有候选词ID后,从Redis ZSET中获取实时权重排序。这样Trie不负责排序,只负责过滤。经测试,性能仅增加0.3ms,但权重实时性大幅提升。
坑5:误以为Trie能处理任何模糊查询
有同事提出需求“输入‘PHP架构’要匹配‘PHP架构设计’”,这其实是前缀匹配可以做到。但下一个需求:“输入‘架设’要匹配‘架构’”?这是中间子串匹配,Trie无能为力。需要结合倒排索引或AC自动机。我最终为这部分需求引入了AC自动机(基于Trie构建失败指针),实现多模式匹配。不过那是另一个话题了。
7. 进阶:AC自动机扩展(简述)
如果业务需要同时支持多个敏感词的快速匹配(如内容安全过滤),可以在Trie树上构建失败指针(fail指针),实现一个AC自动机。构建复杂度O(节点数*字符集),匹配复杂度O(文本长度)。以下给出PHP实现核心:
// 构建失败指针(BFS)
public function buildFailurePointer(): void {
$queue = new SplQueue();
// 第一层节点fail指针指向root
foreach ($this->root->children as $node) {
$node->fail = $this->root;
$queue->enqueue($node);
}
while (!$queue->isEmpty()) {
$parent = $queue->dequeue();
foreach ($parent->children as $char => $child) {
$fail = $parent->fail;
while ($fail !== null && !isset($fail->children[$char])) {
$fail = $fail->fail;
}
$child->fail = ($fail === null) ? $this->root : $fail->children[$char];
// 如果失败节点是终止节点,则当前节点也标记为终止(输出匹配)
if ($child->fail->isEnd) {
$child->isEnd = true;
// 合并输出(需记录所有匹配词)
}
$queue->enqueue($child);
}
}
}
8. 总结(非AI废话)
Trie树适合固定词典的前缀匹配,内存可控,速度极快。如果业务只需要输入前几个字符就出下拉候选,Trie树是最优解。但别拿来干模糊查询或动态排序——那需要组合其他数据结构。我们的线上服务已经稳定运行8个月,P99稳定在3ms内,内存占用23MB(含备份)。代码已上传公司仓库,欢迎拍砖。
# 压测命令示例(使用ab)
ab -n 10000 -c 10 -p search.json -T 'application/json' http://localhost/search/prefix