大家好,欢迎来到今天的“PHP 架构大师班”。我是你们的讲师。
今天我们不讲那些花里胡哨的框架配置,也不讲怎么把 Laravel 部署到 Kubernetes 上。今天,我们要直面一个硬核问题:如何优雅地处理 1000 万条房产数据?
想象一下,你的服务器是一台精干的忍者,但它的背上背着一座大山。这座山就是 1000 万条房产记录。这 1000 万条数据,可能包含着每套房子的坐标、面积、户型、价格、房产证编号、装修风格,甚至还有一张高清的室内照片 URL。
如果你要把这 1000 万条数据全都塞进 PHP 数组里,并在内存里跑一个 SQL 查询,那么在你看完这段话之前,你的服务器就已经因为内存溢出(OOM)而吐白沫了。
我们要做的,不是搬山,而是学会如何“偷梁换柱”。我们要在内存占用和检索速度之间,找到那个完美的平衡点。这就好比你要在一家拥挤的火锅店吃饭,既要吃得快(检索快),又要吃得饱(数据全),还得别撑死(内存占用少)。
来,让我们把代码铺开,开始今天的实战教学。
第一部分:数据的“肥胖症”与 PHP 的内存陷阱
首先,我们要搞清楚,为什么我们不能直接用数组?
假设一条房产记录非常简单:
[
'id' => 10001,
'city' => '北京',
'price' => 5000000,
'area' => 120,
'bedrooms' => 3
];
看起来很小对吧?大约 100 字节。如果只有 100 万条,这甚至不算什么。但如果是 1000 万条呢?
PHP 的内存管理可不是那么“线性”的。 你在定义数组时,PHP 的底层引擎会分配内存。每个数组元素、每个字符串、甚至每个数字,都有一个“外壳”(zval 结构)。
在 64 位系统下,一个空数组结构就有 72 字节的开销。一个字符串变量可能需要 16 字节(8 字节指针 + 8 字节长度)。所以,如果我们要把 1000 万条这种简单的数据塞进 PHP 的堆内存里,计算一下:
- 数据本体:1000万 * 100 字节 = 1 GB
- 数组/结构开销:1000万 * 72 字节 ≈ 720 MB
- 对象引用计数(PHP 的垃圾回收机制):更多…
总计: 简单粗暴地加载全部数据,可能需要占用 2GB 甚至更多的 RAM。
如果你的服务器只有 2GB 内存,加上 PHP 自身、操作系统、数据库连接池的消耗,你的应用会在加载这 1000 万条数据的瞬间,直接变成一个“死机”的雕塑。
所以,我们的第一节课:绝对不要在 PHP 的单次请求周期内,把 1000 万条数据全加载到内存里。
第二部分:磁盘缓存的艺术
既然内存装不下,那我们就去磁盘。这是最原始的缓存方式——把数据存成 JSON 文件。
方案 A:全量 JSON 文件
// 伪代码:生成文件
$data = [];
foreach ($db->getAllProperties() as $row) {
$data[] = $row;
}
file_put_contents('properties.json', json_encode($data));
这行得通吗?当然行。但性能呢?
当你需要查询“北京市朝阳区单价超过 8 万的房产”时,你需要读取这个 1000 万行的 JSON 文件,解析它,然后遍历整个数组。
性能评估:
- 读取速度: 极慢。磁盘 IO 是瓶颈,对于 JSON 这种文本格式,PHP 需要耗时毫秒级甚至秒级来读取并解析。
- 内存占用: 极低。因为我们通过流式处理(例如
fopen,fread,json_decode),每次只处理一部分。
结论: 这种方法适合“冷数据”,即几个月没人查一次的数据。但在高并发的房产网站首页,这绝对是灾难。
第三部分:Redis 的魔法——数据结构就是一切
既然 PHP 的数组太重,JSON 太慢,我们需要一个第三方选手。Redis。
Redis 是内存数据库,但它最强大的地方不在于“内存”,而在于“数据结构”。如果你用对了结构,性能能提升几个数量级。
针对房产数据,我们有几种玩法:
1. 哈希存储:存详情
不要把一条记录存成 Redis 的一个 String(比如 Base64 编码的 JSON),也不要存成独立的 Hash。我们要利用 Redis 的 Hash 数据结构,把一条记录的多个字段存进去。
// 示例:存储一条房产
$redis->hMSet("prop:10001", [
'city' => '北京',
'price' => '5000000',
'area' => '120',
'beds' => '3',
'tags' => '学区房,精装'
]);
// 获取整条记录
$property = $redis->hGetAll("prop:10001");
为什么这比 JSON 好?
- 原子性: 你可以只更新价格,而不需要把整条 JSON 读出来改、再写回去。这在高并发下是神器。
- 内存效率: Redis 对 Hash 内部的字段进行了编码优化(ziplist 或 hashtable),比单纯的字符串拼接紧凑得多。
2. 有序集合:存索引——这是核心!
这是最关键的设计。我们要支持“按城市筛选”和“按价格排序”。
假设用户在搜索框输入“北京”,我们需要获取北京市所有的房产 ID。如果我们将所有房产 ID 放在一个 List 里,每次查询都要遍历 1000 万个 ID。
解决方案:倒排索引。
我们用 Redis 的 ZSET(有序集合)。ZSET 的元素可以包含一个 score,我们可以利用 score 来做索引。
设计思路:
我们将所有房产的 ID 作为 ZSET 的 member。
对于“城市索引”,我们使用多重 ZSET,或者用一个特殊的 Score 来表示城市代码。比如,城市的 ASCII 码作为 Score,房产 ID 作为 Member。
// 添加一条房产进入索引
$cityCode = ord('北京'); // 简单演示,实际可以用拼音首字母或专门的城市 ID
$redis->zAdd("index:city", $cityCode, "prop:10001");
// 添加价格索引
$redis->zAdd("index:price", 5000000, "prop:10001");
查询演示:
现在,用户搜索“北京”。
// 获取所有北京的房产 ID
$cityIds = $redis->zRangeByScore("index:city", $cityCode, $cityCode);
// 结果是 ["prop:10001", "prop:5002", "prop:9980", ...]
速度: 几乎是 O(1) 的速度。Redis 的 Sorted Set 在插入和范围查询上极其高效。
查询演示 2:
用户搜索“北京,价格 400万 – 600万”。
我们需要先通过城市索引找到北京的 ID,然后再遍历这些 ID,去查这些 ID 的实际价格吗?
不,那样太慢了。我们需要联合索引。
高级技巧:将城市和价格合并为一个 Score。
假设我们将 Score 设计为:城市代码 * 100000000 + 价格。
这样,所有北京的房产,其前缀都是 城市代码。所有价格的排序都在后面。
// 生成索引 Score
$score = (ord('北') << 24) + 5000000; // 假设北京编码为 1
$redis->zAdd("index:complex", $score, "prop:10001");
// 查询范围:北京,400万 - 600万
// Score 范围:(1 << 24) + 4000000 到 (1 << 24) + 6000000
$range = $redis->zRangeByScore("index:complex", (1 << 24) + 4000000, (1 << 24) + 6000000);
性能: Redis 只需要在一个 Sorted Set 里做范围查询,速度飞快。然后我们拿到 ID,去 HGETALL 获取详情。
内存占用评估:
Redis 虽然在内存里,但 ZSET 和 Hash 的结构非常紧凑。1000 万条记录,预估内存占用在 1.5GB – 2GB 左右。如果是 4GB 的 Redis 实例,这已经是可以接受的范围了。如果内存不够,我们再往下看。
第四部分:混合策略——大厂的金字塔
如果你说:“老哥,我预算有限,只有 2GB 内存,装不下这 1000 万条数据,而且 Redis 单机扛不住 1000 万的 QPS。”
没关系,我们来设计一个分层缓存策略。这就好比一家超市:
- 一楼(入口): 只有商品标签(索引),指引用户去哪拿。
- 二楼(仓库): 只有最热门的商品(热数据)。
- 地下(冷库): 老旧过季的商品(冷数据)。
1. 索引层:轻量级索引
我们不需要把所有数据都存进 Redis。我们只需要存索引。
建立一个 Redis Hash,Key 是 city:beijing,Value 是一个 List(或者 ZSET 的 ID 列表)。
这个 Hash 很小,只有 ID,没有详细字段。1000 万条 ID 的大小大概只有几十 MB。
2. 数据层:APCu (用户数据缓存)
APCu 是 PHP 进程内的缓存。当用户访问一个热门房源(比如“北京四合院”)时,我们把它加载到 APCu 中。
热门房源可能只有 1%。
- 命中: 从 APCu 读取,速度比 Redis 还快(省去了网络开销),内存占用极低。
- 未命中: 从 Redis 或 MySQL 加载,并存入 APCu。
3. 热数据层:Redis Hash (ZSet)
我们将“最近一周浏览量最高的 100 万条房产”放入 Redis 的 Hash 和 ZSET 中。
当用户搜索时,优先去查这 100 万条。
4. 冷数据层:SQLite 或 文件缓存
对于剩下的 900 万条(冷数据),我们不用 Redis。
我们利用 PHP 的 sqlite3 扩展。SQLite 是一个文件数据库,不需要单独安装,支持索引,查询速度比直接读 JSON 快得多。
我们将这 900 万条数据存在一个 properties.db 文件里。
查询流程:
- 查 Redis 索引(ZSET),拿到符合条件的一组 ID。
- 遍历 ID,先查 APCu(热数据),如果命中,直接返回。
- 如果 APCu 未命中,查 Redis Hash(温数据),如果命中,返回并加载进 APCu。
- 如果 Redis 也没命中,查 SQLite 文件。
第五部分:代码实战——一个完整的房产缓存服务类
好了,理论说多了都要睡觉。我们来写代码。这是一个基于 Redis 索引 + SQLite 冷数据的实现。
环境假设:
- Redis 运行在 6379 端口。
- SQLite 数据库文件
properties.db已存在,包含表properties (id, city, price, json_data)。 - PHP 7.4+。
<?php
class PropertyService {
private $redis;
private $db;
private $cachePrefix = 'prop:';
private $hotDataLimit = 100000; // 只缓存 10 万条热数据到 Redis/内存
public function __construct() {
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
$this->db = new SQLite3('properties.db');
// 确保 SQLite 有索引,速度才快
$this->db->exec('CREATE INDEX IF NOT EXISTS idx_city ON properties(city)');
}
/**
* 搜索房产:北京,价格 300万 - 800万
* 策略:先查 Redis 索引,再查 SQLite
*/
public function search($city, $minPrice, $maxPrice) {
$cityCode = $this->getCityCode($city);
// 1. 生成 Redis 索引的 Score 范围
// 假设 Score 格式:城市编码 << 32 + 价格
$minScore = ($cityCode << 32) + $minPrice;
$maxScore = ($cityCode << 32) + $maxPrice;
// 2. 查询 Redis 索引
$ids = $this->redis->zRangeByScore("idx:$cityCode", $minScore, $maxScore, [
'limit' => [0, 100] // 只查前 100 个结果,避免一次拿太多
]);
$results = [];
foreach ($ids as $id) {
// 3. 获取详情
// 尝试从 Hash 获取(如果有 Redis 缓存)
$detail = $this->redis->hGetAll($this->cachePrefix . $id);
if ($detail) {
$results[] = $detail;
// 如果命中,顺便更新一下热度(简单模拟)
$this->redis->zIncrBy("hot_properties", 1, $id);
} else {
// 4. Redis 没有,查 SQLite (冷数据)
$stmt = $this->db->prepare('SELECT json_data FROM properties WHERE id = :id');
$stmt->bindValue(':id', $id);
$res = $stmt->execute();
$row = $res->fetchArray(SQLITE3_ASSOC);
if ($row) {
$data = json_decode($row['json_data'], true);
$results[] = $data;
// 反哺到 Redis (异步或同步均可,这里为了演示同步)
$this->redis->hMSet($this->cachePrefix . $id, $data);
}
}
}
return $results;
}
/**
* 获取热度最高的房产列表
*/
public function getHotProperties() {
// 获取 Top 10 热门 ID
$hotIds = $this->redis->zRevRange("hot_properties", 0, 10);
$data = [];
foreach ($hotIds as $id) {
// 尝试从 APCu 获取(极热数据)
if (apcu_exists($this->cachePrefix . $id)) {
$data[] = apcu_fetch($this->cachePrefix . $id);
} elseif ($this->redis->exists($this->cachePrefix . $id)) {
// 尝试从 Redis 获取
$data[] = $this->redis->hGetAll($this->cachePrefix . $id);
}
}
return $data;
}
// 简单的城市编码生成器
private function getCityCode($city) {
return ord(substr($city, 0, 1));
}
}
// 使用示例
$service = new PropertyService();
$properties = $service->search('北京', 3000000, 8000000);
echo "找到 " . count($properties) . " 条房产n";
// var_dump($properties);
第六部分:深度剖析——性能与内存的权衡艺术
代码写完了,但这只是开始。我们来聊聊背后的原理。
1. 为什么用 zRangeByScore 而不是 zRange?
zRange 会返回集合里的所有元素,包括那些不属于你范围之外的元素。而 zRangeByScore 告诉 Redis:“我只想要 Score 在这个区间内的,给我前 100 个”。这大大减少了网络传输量和 CPU 的排序开销。
2. 代码中的“缓存穿透”防御
注意看代码里的 hot_properties。
如果我们缓存了热门数据,那么当用户搜索“北京 400万”时,我们直接拿到的都是缓存数据,完全不需要去查 SQLite。这意味着 90% 的请求都在瞬间完成,而且没有磁盘 IO。
3. 内存碎片化
Redis 使用了高效的 SDS(简单动态字符串)和 Hash 表。但是,如果你频繁地删除和插入数据,Redis 的内存碎片率(mem_fragmentation_ratio)可能会升高。
- 策略: 在非高峰期(比如凌晨),运行一个后台脚本,将 Redis 中的热点数据转存到 SQLite,或者做一次重载。
- PHP APCu 的坑: APCu 是进程内的。如果你有 10 个 PHP-FPM 进程,每个进程都要加载这 10 万条热门数据,那内存瞬间就翻倍了。
- 解决方案: 只用 APCu 缓存单个用户的会话数据,或者缓存计算结果,不要试图把全量数据塞进 APCu。对于全量数据,坚持用 Redis。
4. 序列化与压缩
在 json_decode 这一步,对于 1000 万条数据,解析 JSON 是昂贵的 CPU 操作。
优化:
如果 SQLite 里存的是纯文本 JSON,json_decode 会非常慢。我们可以考虑 SQLite 里存二进制数据(BLOB),或者使用更高效的压缩库。
在 PHP 中,你可以使用 LZF 扩展。
$lzf = new LZF();
$compressed = $lzf->compress($jsonData);
$uncompressed = $lzf->decompress($compressed);
虽然加解压会有 CPU 开销,但对于冷数据的读取(通常发生在后台任务或偶尔的低峰查询),这是换取 IO 速度的好方法。
第七部分:终极挑战——如何处理更新?
到现在为止,我们只谈了读。房产系统也要更新,比如房东降价了。
问题: 如果房东降价了,我们改了 MySQL,怎么让缓存同步?
1. 失效策略
最简单的策略:直接删除缓存。
当收到“降价”请求时:
- 更新 MySQL。
- 执行
$redis->del("prop:10001")。 - 执行
$this->db->exec("UPDATE properties SET ...")。
缺点: 并发情况下可能出现“脏读”。比如 A 用户正在读旧价格,B 用户正在更新降价,A 读完了,B 才删缓存。
2. 懒加载
在上面的 search 代码里,我们实际上已经用了懒加载。
$detail = $this->redis->hGetAll(...) 如果返回空,我们直接查 SQLite。SQLite 更新很快(比 MySQL 简单,因为单文件锁机制),所以即便缓存删了,用户下次刷新也能看到新价格。
3. 主动推送 (Pub/Sub)
对于极度敏感的实时数据(比如正在直播看房),可以使用 Redis 的 Pub/Sub 功能。
卖家降价 -> 发布消息 -> 所有在线用户的 PHP 进程接收消息 -> 删除对应房产的本地缓存 -> 下次查询重新加载。
总结与展望
面对 1000 万条房产记录,我们没有任何魔法。
- 不要把所有数据塞进 PHP 数组,那是内存泄漏的温床。
- 不要用纯文本 JSON 做全量缓存,那是 I/O 瓶颈。
- 要用 Redis 的 Sorted Set 做多维索引,这是速度的引擎。
- 要用 分层架构,把 10% 的热门数据留在 RAM 里,把 90% 的冷数据扔到磁盘上(SQLite 或 文件)。
这不仅仅是一个技术选型问题,更是一种工程思维。你要时刻权衡:
- 我愿意多花多少 CPU 去压缩数据?(换取内存)
- 我愿意多花多少内存去存索引?(换取 IO 速度)
- 我愿意接受多大的延迟?(决定用多少缓存)
这就是架构师的乐趣所在,在约束的钢丝绳上跳舞,既要快,又要稳,还要省。
好了,今天的课就到这里。如果你学会了,记得回去把你的 1000 万数据表重构一下。别让你的硬盘被这些枯燥的数字憋炸了!
(下课!)