受《PHP 核心技術與最佳實踐》啟發,本文展示如何用純 PHP 實現一個高性能的 嵌入式 Key-Value 存儲引擎。設計目標:
add、get、delete 三大核心操作.idx 索引文件 + .dat 數據文件分為兩個區域:
DB_BUCKET_SIZE * 4 字節(每桶一個 4 字節無符號整數,指向索引記錄區鏈表頭)。DB_INDEX_SIZE = 128 + 12 = 140 字節:| 偏移 | 長度 | 字段 | 說明 |
|---|---|---|---|
| 0 | 4 字節 | next_offset | 同桶下一條記錄在索引區的偏移(0 表示鏈表結束) |
| 4 | 128 字節 | key | 鍵名,不足右填充 \x00 |
| 132 | 4 字節 | data_offset | 數據在 .dat 文件中的偏移 |
| 136 | 4 字節 | data_len | 數據長度 |
純粹順序追加存儲 Value,無結構化元數據,讀取時由索引指導偏移與長度。
經典字符串哈希算法,分布均勻、計算快速:
private function times33Hash(string $key): int {
$key = substr(md5($key), 0, 8); // 取 MD5 前 8 字符增加熵
$hash = 0;
for ($i = 0; $i < 8; $i++) {
$hash += 33 * $hash + ord($key[$i]);
}
return $hash & 0x7FFFFFFF; // 保證非負整數
}
<?php
define('DB_BUCKET_SIZE', 262144); // 2^18 個桶
define('DB_KEY_SIZE', 128); // Key 最大長度
define('DB_INDEX_SIZE', DB_KEY_SIZE + 12);
define('DB_SUCCESS', 1);
define('DB_FAILURE', -1);
define('DB_KEY_EXISTS', -2);
class DB {
private $idx_fp;
private $dat_fp;
private $closed = false;
/** 打開/創建數據庫 */
public function open(string $pathName): int {
$idxPath = $pathName . '.idx';
$datPath = $pathName . '.dat';
$isNew = !file_exists($idxPath);
$mode = $isNew ? 'w+b' : 'r+b';
$this->idx_fp = fopen($idxPath, $mode);
$this->dat_fp = fopen($datPath, $mode);
if (!$this->idx_fp || !$this->dat_fp) return DB_FAILURE;
if ($isNew) {
// 初始化桶指針區為全 0
$zero = pack('L', 0);
for ($i = 0; $i < DB_BUCKET_SIZE; $i++) {
fwrite($this->idx_fp, $zero, 4);
}
}
return DB_SUCCESS;
}
/** 插入記錄(Key 存在則返回 DB_KEY_EXISTS) */
public function add(string $key, string $value): int {
if (strlen($key) > DB_KEY_SIZE) return DB_FAILURE;
$bucketOffset = ($this->times33Hash($key) % DB_BUCKET_SIZE) * 4;
$idxEnd = (int)fstat($this->idx_fp)['size'];
$datEnd = (int)fstat($this->dat_fp)['size'];
$valLen = strlen($value);
// 構建索引記錄塊
$block = pack('L', 0); // next = 0
$block .= $key;
$block .= str_repeat("\x00", DB_KEY_SIZE - strlen($key)); // 填充
$block .= pack('L', $datEnd); // data_offset
$block .= pack('L', $valLen); // data_len
// 讀取桶頭指針
fseek($this->idx_fp, $bucketOffset, SEEK_SET);
$head = unpack('L', fread($this->idx_fp, 4))[1];
if ($head === 0) {
// 桶為空,直接寫入
fseek($this->idx_fp, $bucketOffset, SEEK_SET);
fwrite($this->idx_fp, pack('L', $idxEnd), 4); // 更新桶頭
fseek($this->idx_fp, 0, SEEK_END);
fwrite($this->idx_fp, $block, DB_INDEX_SIZE);
fseek($this->dat_fp, 0, SEEK_END);
fwrite($this->dat_fp, $value, $valLen);
return DB_SUCCESS;
}
// 桶非空,遍歷鏈表檢查 Key 是否已存在
$pos = $head;
$prev = 0;
while ($pos) {
fseek($this->idx_fp, $pos, SEEK_SET);
$rec = fread($this->idx_fp, DB_INDEX_SIZE);
$currKey = substr($rec, 4, DB_KEY_SIZE);
if (strncmp($key, $currKey, strlen($key)) === 0) {
return DB_KEY_EXISTS; // Key 已存在
}
$prev = $pos;
$pos = unpack('L', substr($rec, 0, 4))[1];
}
// 追加到鏈表尾部
fseek($this->idx_fp, $prev, SEEK_SET);
fwrite($this->idx_fp, pack('L', $idxEnd), 4); // 更新前驅 next
fseek($this->idx_fp, 0, SEEK_END);
fwrite($this->idx_fp, $block, DB_INDEX_SIZE);
fseek($this->dat_fp, 0, SEEK_END);
fwrite($this->dat_fp, $value, $valLen);
return DB_SUCCESS;
}
/** 查詢記錄 */
public function get(string $key): ?string {
$bucketOffset = ($this->times33Hash($key) % DB_BUCKET_SIZE) * 4;
fseek($this->idx_fp, $bucketOffset, SEEK_SET);
$pos = unpack('L', fread($this->idx_fp, 4))[1];
while ($pos) {
fseek($this->idx_fp, $pos, SEEK_SET);
$rec = fread($this->idx_fp, DB_INDEX_SIZE);
$currKey = substr($rec, 4, DB_KEY_SIZE);
if (strncmp($key, $currKey, strlen($key)) === 0) {
$dataOff = unpack('L', substr($rec, DB_KEY_SIZE + 4, 4))[1];
$dataLen = unpack('L', substr($rec, DB_KEY_SIZE + 8, 4))[1];
fseek($this->dat_fp, $dataOff, SEEK_SET);
return fread($this->dat_fp, $dataLen);
}
$pos = unpack('L', substr($rec, 0, 4))[1];
}
return null;
}
/** 刪除記錄(僅標記索引移除,數據文件不物理刪除,避免空洞) */
public function delete(string $key): int {
$bucketOffset = ($this->times33Hash($key) % DB_BUCKET_SIZE) * 4;
fseek($this->idx_fp, $bucketOffset, SEEK_SET);
$head = unpack('L', fread($this->idx_fp, 4))[1];
$curr = $head;
$prev = 0;
while ($curr) {
fseek($this->idx_fp, $curr, SEEK_SET);
$rec = fread($this->idx_fp, DB_INDEX_SIZE);
$next = unpack('L', substr($rec, 0, 4))[1];
$currKey = substr($rec, 4, DB_KEY_SIZE);
if (strncmp($key, $currKey, strlen($key)) === 0) {
// 從鏈表中移除
if ($prev === 0) {
fseek($this->idx_fp, $bucketOffset, SEEK_SET);
} else {
fseek($this->idx_fp, $prev, SEEK_SET);
}
fwrite($this->idx_fp, pack('L', $next), 4);
return DB_SUCCESS;
}
$prev = $curr;
$curr = $next;
}
return DB_FAILURE;
}
public function close(): void {
if (!$this->closed) {
fclose($this->idx_fp);
fclose($this->dat_fp);
$this->closed = true;
}
}
private function times33Hash(string $key): int { /* 同前 */ }
}
<?php
$db = new DB();
$db->open('/var/www/data/mydb');
// 寫入 10,000 條
$start = microtime(true);
for ($i = 0; $i < 10000; $i++) {
$db->add("key$i", "value$i");
}
echo "寫入耗時: " . (microtime(true) - $start) * 1000 . " ms\n"; // ~793ms
// 讀取 10,000 條
$start = microtime(true);
for ($i = 0; $i < 10000; $i++) {
$db->get("key$i");
}
echo "讀取耗時: " . (microtime(true) - $start) * 1000 . " ms\n"; // ~149ms
$db->close();
測試環境:PHP 7.4、SSD、單進程。讀取性能接近 Redis 1/10,寫入受限於 fsync 頻率。若需更高吞吐,可增加 fwrite 後調用 fflush 批量提交,或引入內存緩衝區定期刷盤。
| 優勢 | 局限 |
|---|---|
| 零依賴、零配置、易部署 | 無並發控制(多進程需外部鎖) |
| 嵌入式應用、邊緣計算、輕量級緩存 | 不支援範圍查詢、事務、備份恢復 |
| 數據持久化、重啟不丟失 | 刪除不回收空間,長期運行需重建 |
這個微型 NoSQL 引擎展示了數據庫核心原理:哈希索引 + 順序存儲 + 拉鏈衝突解決。雖然生產環境仍建議使用 Redis/RocksDB 等成熟方案,但理解其內部機制對於優化 SQL 索引、設計緩存策略、排查性能瓶頸極有裨益。