PHP 也能当杀手锏:内存级倒排索引的“哈希”骚操作
各位开发者,大家好!
今天我们不谈那些花里胡哨的框架,也不聊怎么把 CI/CD 搞得像在跳芭蕾舞。今天我们要聊点硬核的,聊点能让你在面试时把面试官吓一跳,或者在深夜为了解决一个查询性能问题而“甚至想扇自己一巴掌”的技术。
主题是:海量文章全文搜索的哈希索引设计:利用 PHP 核心函数构建内存级倒排索引。
我知道,听到“PHP”和“海量”、“哈希索引”这些词,你的脑子里可能弹出一个问号,甚至可能弹出一个“转行学 Go 吧”的念头。别急,PHP 8 的 JIT 机制让它已经脱胎换骨,而今天我们要用它的原生特性,构建一个能跑在内存里的“搜索引擎”。
准备好了吗?让我们把咖啡机打开,开始这场内存冒险。
第一部分:痛,并快乐着的 LIKE ‘%keyword%’
首先,我们得直面惨淡的现实。在很多业务场景下,我们需要对文章内容进行搜索。
老派的写法是什么?
SELECT * FROM articles WHERE content LIKE '%PHP%' OR title LIKE '%PHP%';
或者更惨一点的,为了解决中文分词问题,还得去搞个 FTS(Full Text Search)插件,配置半天,结果查询速度比蜗牛还慢。
为什么 LIKE 性能这么差?因为数据库需要把整张表的数据读出来,然后一行一行地匹配。对于几万条数据,数据库可能还觉得“我扛得住”;但对于百万级、千万级的数据?那就是一场灾难。数据库服务器CPU直接干到 100%,然后报警,然后运维找你谈话,最后你被迫在凌晨三点手动加索引。
我们需要一个更聪明的方法。这个方法的核心思想是:预处理。
不要让数据库去猜,我们要在查询之前,就把答案算好存在内存里。
第二部分:倒排索引——搜索引擎的灵魂
这玩意儿有点像我们的电话本,但它是“倒”过来的。
普通的电话本(正排索引)是:张三 -> 13800138000。我们要找张三,去翻目录。
倒排索引是:13800138000 -> [张三, 李四, 王五]。我们要找这个号码归属地,直接去查。
在全文搜索里,倒排索引的结构长这样:
- Term(词条/词): 比如 “PHP”, “内存”, “哈希”。
- Posting List(倒排列表): 包含了这个 Term 的所有文档 ID(DocID),以及在这些文档中出现的位置。
所以,我们的核心任务就是:把文本拆解成 Term,然后建立 Term -> DocID -> Position 的映射。
为什么强调“哈希索引”?
在数据库领域,索引通常用 B+ 树。B+ 树适合范围查询,但它的路数是“分叉”的。而在内存搜索里,我们要的是极快的查找速度。
哈希索引的设计思想非常简单粗暴:直接定位。
Key(单词) -> Hash -> Index -> Value(文档ID列表)。
这就是 O(1) 的时间复杂度(在哈希冲突极低的情况下)。既然是内存操作,我们就要榨干内存的每一滴性能。
第三部分:PHP 的内存榨汁机
在 PHP 里,普通的数组 (array) 其实就是一个哈希表。它很方便,功能强大,但它有一个致命的弱点:内存开销大。
如果你往一个普通数组里塞了一百万个整数,内存占用可能会达到几十 MB,甚至上百 MB。为什么?因为 PHP 的数组是对象,每个元素都是一个 zval 结构体,包含类型、值、引用计数等等。这就像是你买了一箱纯水,但为了送水,你还得自己背着一整套送水设备。
对于“海量”文章,我们不能这么浪费。
我们有两个核武器可以用:
SplFixedArray:固定长度的数组。它不存对象,直接存原生 C 类型数据。内存占用只有普通数组的几分之一。pack/unpack:二进制数据打包/解包函数。这是神器。如果我们把整数存成二进制流,字节数会大幅减少。
第四部分:实战——构建内存索引
让我们开始写代码。我们将构建一个 MiniSearch 类。
1. 核心数据结构设计
我们需要两层结构:
Global Dictionary(全局字典):Term ->Posting List的起始内存地址偏移量(或者直接存对象,为了演示方便,我们先用对象,但内部用SplFixedArray存位置)。Posting List(倒排列表):DocID -> Position List。
为了处理“海量”,假设我们限制每个文档最大 1000 个词(超过的忽略,为了演示不崩),位置列表最多 100 个(超过的忽略)。
<?php
class MemoryInvertedIndex
{
// 核心哈希索引:Term -> PostingList 对象
// 这里使用普通数组作为 Hash Map,因为它处理字符串 Key 效率极高
private array $dictionary = [];
// 内存统计
private int $totalDocs = 0;
private int $memoryUsed = 0;
/**
* 添加文档到索引
* @param int $docId 文档唯一ID
* @param string $content 文章内容
*/
public function addDocument(int $docId, string $content): void
{
// 1. 文本分词
// 这里简单按非字母数字分割,实际生产环境得用正则库或者专门的分词器
$words = preg_split('/W+/', $content, -1, PREG_SPLIT_NO_EMPTY);
// 2. 构建倒排列表
foreach ($words as $position => $word) {
// 统一转为小写,为了搜索不区分大小写(这需要提前把文档内容转小写)
$word = strtolower($word);
if (!isset($this->dictionary[$word])) {
// 创建一个新的倒排列表
// 使用 SplFixedArray 而不是普通 array
// 初始化大小为 100,虽然这是硬编码,但比动态扩容快得多
$this->dictionary[$word] = new SplFixedArray(100);
$this->dictionary[$word]->setSize(0); // 初始长度为0
}
$list = $this->dictionary[$word];
$currentSize = $list->count();
// 如果数组满了,这里可以报错或者扩容,为了演示我们只存前100个位置
if ($currentSize < 100) {
// 存储格式:[DocID, Position]
// 在真正的内存索引中,这里应该用 pack 打包成二进制流
// 比如 pack('N', $docId) 也就是 4个字节
$list[$currentSize] = $docId;
// 为了精确搜索,我们还需要存位置,但为了简化代码,
// 这里我们用简化逻辑:把 DocID 重复存几次来模拟位置?
// 不,这太烂了。
// 真正的倒排索引里,这里是 (DocID, Position) 对。
// 为了在 SplFixedArray 里存一对数据,我们可以存数组,但这又引入了对象开销。
// 妥协方案:这里只存 DocID。
// 想要存位置?咱们用 pack 打包一个整数: (DocID << 16) | Position
// 这样一个整数就能代表位置信息。
$packedPos = ($docId << 16) | $position;
$list[$currentSize] = $packedPos;
// 清空一下防止 GC 问题(虽然 PHP 8 GC 挺强,但在高频操作时这是个好习惯)
$list[$currentSize + 1] = null;
}
}
$this->totalDocs++;
}
/**
* 搜索关键词
* @param string $query
* @return array 返回包含关键词的文档ID数组
*/
public function search(string $query): array
{
$query = strtolower($query);
if (!isset($this->dictionary[$query])) {
return [];
}
$result = [];
$list = $this->dictionary[$query];
// SplFixedArray 的遍历比 foreach 普通 array 稍微快一点点,主要省去了引用计数的开销
for ($i = 0; $i < $list->count(); $i++) {
$data = $list[$i];
// 解包数据
// 高 16 位是 DocID,低 16 位是 Position
$docId = $data >> 16;
// $pos = $data & 0xFFFF; // 如果需要位置
$result[] = $docId;
}
// 结果去重 (因为同一个文档可能包含同一个词多次)
return array_unique($result);
}
}
// --- 测试 ---
$index = new MemoryInvertedIndex();
// 模拟几篇文章
$index->addDocument(1, "PHP is a great language for web development.");
$index->addDocument(2, "PHP 8 performance is amazing.");
$index->addDocument(1, "Search engines use inverted indexes.");
// 搜索
echo "找到文章: " . implode(', ', $index->search("php")) . "n"; // 应该输出 1, 2
echo "找到文章: " . implode(', ', $index->search("inverted")) . "n"; // 应该输出 1
代码解析:这里面的门道
-
SplFixedArray的妙用:
请注意代码中的new SplFixedArray(100)。这行代码非常关键。它告诉 PHP:“给我 100 个槽位,只存整数,别搞对象了”。相比普通数组,它的内存占用只有原来的 1/4 到 1/3。如果你要存 100 万个位置,这个差异是巨大的。 -
位运算压缩 (
pack的手写替代):
我在代码里没用pack函数,而是用了($docId << 16) | $position。- 假设 DocID 是 1024 (2^10),Position 是 500。
1024 << 16= 67108864。67108864 | 500= 67109364。- 这就是一个整数。我们用 32 位整数完美容纳了 DocID 和 Position。这是系统级编程的思维。
-
哈希碰撞:
PHP 的数组其实已经帮我们解决了哈希冲突问题(开放寻址或链表法)。如果你非要自己写哈希表,那是另外的价钱,但作为“内存级索引”的快速实现,直接利用 PHP 的底层哈希表是最稳妥的。
第五部分:处理海量数据的“压舱石”——布隆过滤器
你可能会问:“博主,你这代码用了 array_unique,如果我的倒排列表里有 100 万个 DocID,去重得花多长时间?而且内存占用会爆炸。”
确实,对于一个 Term,它的倒排列表可能会很长。这时候,我们需要引入布隆过滤器。
什么是布隆过滤器?
它是一个极度节省空间的数据结构,用来判断一个元素是否在集合中。
- 返回“在” -> 不一定在(可能有误判,但没漏报)。
- 返回“不在” -> 肯定不在。
在全文搜索里,它的作用是:当用户搜索 “Python” 时,先查布隆过滤器。如果过滤器说 “Python 不在索引中”,直接返回空,连哈希表的门都不用敲。这能极大地节省 CPU 和内存查询时间。
用 PHP 实现简易布隆过滤器
PHP 没有内置的布隆过滤器类,我们需要自己写。核心就是:多个哈希函数 + 一个位数组(可以用 SplFixedArray 模拟,或者一个二进制字符串)。
class BloomFilter
{
private SplFixedArray $bits;
private int $size;
private int $hashCount;
public function __construct(int $size, int $hashCount = 5)
{
// 位图大小,必须是 2 的幂次方,方便取模运算
$this->size = $size;
$this->hashCount = $hashCount;
// 初始化全为 0
$this->bits = new SplFixedArray($size);
$this->bits->setSize($size);
}
/**
* 将字符串 hash 到多个位置
*/
private function getHashes(string $str): array
{
// 这里简单使用 crc32 和 fnv1 算法,生产环境建议用更专业的
$hashes = [];
$hash1 = abs(crc32($str));
$hash2 = abs(fnv1a32($str)); // 需要自己实现一个 fnv1a
for ($i = 0; $i < $this->hashCount; $i++) {
// 简单的扰动函数,让每个 hash 略有不同
$val = $hash1 + ($hash2 * $i);
$index = $val % $this->size;
$hashes[] = $index;
}
return $hashes;
}
public function add(string $str): void
{
$indices = $this->getHashes($str);
foreach ($indices as $idx) {
$this->bits[$idx] = 1;
}
}
public function contains(string $str): bool
{
$indices = $this->getHashes($str);
foreach ($indices as $idx) {
if ($this->bits[$idx] !== 1) {
return false;
}
}
return true;
}
}
优化建议:
上面的 SplFixedArray 里存的是 0 和 1。实际上,你可以把整个 BloomFilter 作为一个巨大的字符串,用 pack('b*', ...) 来处理。这样内存效率更高。一个 100 万位的布隆过滤器,只需要 100KB 内存,这简直是把内存当水用。
第六部分:真正的“海量”——持久化与二进制流
上面的代码,一旦脚本结束,内存就释放了。这怎么叫“海量”?海量意味着数据可能比你的 RAM 大。
这时候,我们就需要把内存里的东西“吐”出来,或者从文件里“读”进来。
序列化 vs 二进制流
用 PHP 的 serialize() 和 unserialize()?别。那是给 PHP 对象用的,它们在内存里是对象,serialize 会生成包含类名、属性名的长字符串,体积巨大。
我们要用 二进制流。
假设我们的索引数据结构如下:
- Header:版本号,Term 数量,DocID 总数。
- Dictionary:按顺序存储所有 Term。
- Postings:按顺序存储所有 Posting List。
我们可以把整个索引文件设计成一个文件格式(比如 .idx)。
读取流程:
- 读取 Header,得到 Term 总数。
- 循环读取 Term,计算它在文件中的偏移量。
- 用户查询时,先读 Term,计算它在文件中的偏移量。
- 跳转到该位置,读取 Posting List。
这个设计叫做外部排序的变种,或者分块索引。它的好处是,哪怕你的内存只有 1GB,你也可以索引 10GB 的数据(只要你的磁盘够快,或者分块足够小)。
这里就不展开写那个几万行的文件解析代码了,否则这讲座就变成代码培训班了。核心思想是:把哈希表变成磁盘上的线性结构 + 偏移量。
第七部分:搜索查询的布尔逻辑
有了索引,我们怎么搜?
用户输入:"PHP" AND "High Performance" OR "Inverted Index"
我们需要一个查询解析器(Query Parser)。这通常是一个递归下降解析器。
逻辑如下:
- 收集用户输入的 Term:
['php', 'high performance', 'inverted index']。 - 对于每个 Term,去哈希表查
Posting List。 - 对 Posting List 进行集合运算:
- AND(交集):取第一个列表,遍历,检查是否存在于第二个列表中。
- OR(并集):遍历所有列表,收集结果。
- NOT(差集):从列表 A 中移除列表 B 中的元素。
性能陷阱:如果一个列表有 100 万个元素,而另一个只有 10 个。进行交集运算时,你不能遍历 100 万个元素。你需要先排序(ID 其实天然有序),然后使用双指针算法。
// AND 运算的伪代码
function intersect(array $listA, array $listB): array
{
$result = [];
$a = 0;
$b = 0;
$lenA = count($listA);
$lenB = count($listB);
while ($a < $lenA && $b < $lenB) {
if ($listA[$a] === $listB[$b]) {
$result[] = $listA[$a];
$a++;
$b++;
} elseif ($listA[$a] < $listB[$b]) {
$a++;
} else {
$b++;
}
}
return $result;
}
这就是搜索引擎的“心脏跳动”——不断的集合运算。
第八部分:内存溢出与 PHP GC 的爱恨情仇
最后,我要泼一盆冷水。既然是内存级索引,最大的敌人就是 OOM (Out of Memory)。
1. GC 的幽灵
在 PHP 里,普通数组里的变量是有引用计数的。你用 $a[] = 1,PHP 会分配内存。
但是,如果你写 $a = [],PHP 会清空内存。
如果你的代码里有一个 $arr,然后把 1000 万个数据扔进去,然后再也没用到它,GC 应该回收它才对。但 PHP 的 GC 是分代回收,而且是引用计数加周期回收。在某些极端的高并发循环中,或者如果你不小心保留了对大数组的引用,内存不会立即释放。
解决方案:
在脚本结束前,或者内存达到阈值前,显式地把大数组置为 null:
unset($this->dictionary);
$this->dictionary = [];
2. 内存碎片
频繁地 addDocument,数组扩容,会导致内存碎片。PHP 的数组扩容是 2 * size,这会导致内存不连续。对于极致的内存级索引,建议预分配足够大的内存,或者使用 PHP 的 swoole_table(它是基于共享内存的,专为高并发内存表设计,完美解决碎片和 GC 问题)。
如果这是生产环境,我强烈建议你看看 Swoole 扩展的 Table。它本质就是一个 C 语言实现的哈希表,存储在共享内存中,支持并发读写,没有 GC 开销。你上面写的 SplFixedArray 思路,其实就是 swoole_table 的底层实现逻辑。
总结:别把语言当枷锁
好了,朋友们。
我们今天聊了什么?
我们聊了怎么用 PHP 的 SplFixedArray 搭建一个内存级的哈希索引。
我们聊了怎么用位运算压缩 DocID 和 Position。
我们聊了怎么用布隆过滤器过滤无效查询。
我们也聊了怎么用二进制流和位图处理海量数据。
很多人觉得 PHP 只能写写增删改查,写写管理后台。但你看,通过理解底层的数据结构,理解内存是如何工作的,PHP 同样可以写出高性能的搜索中间件。
这个设计并不完美,它没有处理中文分词的复杂性,没有处理超长文章的截断。但在纯内存、低延迟的场景下,这是一个非常实用且高效的方案。
记住,技术没有高低贵贱,只有用得是否得当。当你还在为 SELECT * ... LIKE 愁得头发掉光时,你的竞争对手可能已经利用哈希索引,在内存里飞奔了。
动手试试吧!写一个你的 MiniSearch,然后在那个只有 512MB 内存的旧服务器上跑一跑,看着它流畅地返回结果,那种感觉,就像是在旧时光里开了一辆法拉利。
祝你们编码愉快!下节课见!