[开发]SplitDB v2.3 架构方案

👑Lv.11 元老 🌞 骨干会员
2026-10-24 00:14:35

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:暂存20260913/Splitdb V2.3 白皮书.txt。

SplitDB v2.3 架构方案

SplitDB — Sharded SQLite for the rest of us.

文档用途:架构定稿白皮书、开发规范、项目技术文档

适用项目:Sosite 社区底层存储引擎

基线版本:v2.3(冻结,正式启动开发)

前言与架构总览

本方案为 SplitDB v2.3 最终定稿工程方案,面向中小型私有化社区、独立站长设计,基于原生 SQLite 构建分片存储系统。

核心思路:用时序分区 + 哈希分桶解决 SQLite 单文件锁竞争;采用分片作为唯一数据源,索引 / 搜索 / 统计为异步二级视图,具备完整自愈能力,无需额外部署 MySQL、Redis、消息队列。

方案精准适配低资源服务器(1 核 512M ~ 1 核 1G),DAU 0~8000 稳定舒适运行;文档包含设计理念、目录规范、建表语句、完整核心 PHP 源码、运维脚本、开发红线、测试验收清单,所有内容固化,可直接进入开发。

潜在风险提示:

global_id.sqlite 为写入单点,高并发场景后续可拓展 ID 预分配机制;

异步架构存在最终一致性,允许短暂读写延迟,业务层需接纳该特性;

浏览计数模块依赖 APCu 拓展,需要准备无 APCu 降级方案;

文件原子写入基于 Linux 系统规范,Windows 环境存在兼容性差异。

优化增补规范:

备份优先级:bucket 分片文件(真相源)>其他索引库;索引库全部支持从分片一键重建;

文档底部补充部署环境最低要求:PHP ≥7.4(支持 PDO RETURNING、APCUIterator);

后续开发同步编写【一键重建全部索引】CLI 工具。

一、核心设计理念

表格

编号 原则

1 Bucket 分片 = 唯一真实数据源(永不丢失、永不错乱、可全量重建所有索引)

2 全局索引、搜索、统计 = 二级缓存视图(最终一致性,允许异步延迟)

3 读无限扩容、写可控分片、复杂查询全部预计算 / 异步化

二、适用场景与边界

✅ 适用场景

中小型社区、个人站长、私有化部署

低资源服务器(1 核 512M / 1 核 1G 流畅运行)

DAU 0~8000 完全舒适,DAU 8000~15000 限流可控稳定运行

无需 MySQL、无独立数据库进程、低内存、低 IO、部署极简

❌ 不适用场景

超高并发刷屏、电商秒杀、复杂多表 JOIN 统计、大型门户

应对策略:可后期平滑切换至 MySQL 驱动(适配器模式已预留)

三、品牌标识

表格

项目 内容

项目名称 SplitDB

中文别称 分片之光

标语 Sharded SQLite for the rest of us.

Logo 断裂的 S + 数据桶,绿灰配色

配色 SQLite 绿(#8FBC8F)+ 深灰(#2F2F2F)

开源协议 MIT

命名空间 SplitDB

Docker 镜像 sosite/splitdb:latest

四、整体架构目录(定稿)

plaintext

data/

├── meta/

│ ├── main_index.sqlite # 帖子主索引(列表、分页、路由)

│ ├── search_index.sqlite # FTS5 全文检索库

│ ├── stats_cache.sqlite # 预计算全站统计

│ ├── task_queue/ # 多队列目录

│ │ ├── queue_0.sqlite

│ │ ├── queue_1.sqlite

│ │ └── queue_2.sqlite

│ └── global_id.sqlite # 全局 ID 生成器

├── bucket/

│ ├── active/

│ │ ├── 2026Q3/

│ │ │ ├── 0.sqlite ~ 31.sqlite

│ │ └── 2026Q4/

│ │ └── 0.sqlite ~ 31.sqlite

│ └── archive/ # 18 个月前冷数据

├── extern/

│ └── 2026/Q3/0/12345.txt

├── runtime/

├── lock/

└── log/

五、分区与路由规则

5.1 一级分区:季度时序分区

新数据永远写入当前季度目录

超过 18 个月的数据自动归档至 archive/

5.2 二级分区:哈希分桶

默认每季度 32 桶(可后台配置)

写入路由:ID % 桶数量

5.3 路由重大优化

写入时:计算桶路径

读取时:永远以 main_index.bucket_path 为准,不再二次哈希

未来季度可直接升级 32 → 64 → 128 桶,历史数据零迁移、零失效

六、SQLite 统一强制底层参数

sql

PRAGMA busy_timeout = 5000;

PRAGMA journal_mode = WAL;

PRAGMA synchronous = NORMAL;

PRAGMA cache_size = -20000;

PRAGMA temp_store = MEMORY;

PRAGMA foreign_keys = OFF;

七、数据表结构

7.1 Bucket 分片内表结构

topic(主帖表)

sql

CREATE TABLE topic (

id INTEGER PRIMARY KEY,

uid INTEGER,

title TEXT,

create_time INTEGER,

update_time INTEGER,

status TINYINT DEFAULT 0,

extern_path TEXT

);

reply(回复表)

sql

CREATE TABLE reply (

id INTEGER PRIMARY KEY,

pid INTEGER,

uid INTEGER,

create_time INTEGER,

extern_path TEXT,

status TINYINT DEFAULT 0

);

7.2 main_index.sqlite

sql

CREATE TABLE topic_index (

id INTEGER PRIMARY KEY,

uid INTEGER,

title TEXT,

create_time INTEGER,

last_reply_time INTEGER,

view_count INTEGER DEFAULT 0,

status TINYINT DEFAULT 0,

bucket_path TEXT

);

CREATE INDEX idx_create_time ON topic_index (create_time DESC);

7.3 global_id.sqlite

sql

CREATE TABLE id_generator (

table_name TEXT PRIMARY KEY,

last_id INTEGER DEFAULT 0

);

INSERT OR IGNORE INTO id_generator (table_name, last_id) VALUES ('topic', 0);

INSERT OR IGNORE INTO id_generator (table_name, last_id) VALUES ('reply', 0);

7.4 search_index.sqlite(FTS5)

sql

CREATE VIRTUAL TABLE search_index USING fts5 (

title,

summary,

topic_id UNINDEXED

);

7.5 stats_cache.sqlite

版块总数、今日发帖、用户发帖、热度数据(凌晨预计算)

7.6 task_queue(多队列)

sql

CREATE TABLE task_queue (

id INTEGER PRIMARY KEY,

type TEXT,

data TEXT,

status TEXT DEFAULT 'pending',

create_time INTEGER,

update_time INTEGER DEFAULT 0,

retry_count INTEGER DEFAULT 0

);

CREATE INDEX idx_status ON task_queue (status);

CREATE INDEX idx_update_time ON task_queue (update_time);

八、读写流程

8.1 写入流程

表格

步骤 操作

1 从 global_id.sqlite 原子获取下一个 ID(带重试机制)

2 计算季度分区 + 哈希桶:ID % 桶数

3 LRU 取桶连接,写入 Bucket(含 ID、标题、正文外置)

4 随机写入 3 个队列文件之一(打散锁竞争)

5 前端直接返回成功(带上 topic_id 和 bucket_path)

8.2 读取流程

表格

场景 数据源

列表、首页、分页 main_index

帖子详情 bucket_path → 单桶

搜索 search_index → 按需补详情

九、PHP-FPM 适配核心方案

9.1 单进程 LRU 轻量连接池

限制单进程最多 8 个常驻句柄

请求结束自动清空

9.2 禁止持久连接

禁用 ATTR_PERSISTENT

9.3 系统层兜底

bash

ulimit -n 65535

php-fpm rlimit_files = 65535

十、五个核心模块代码(v2.3 最终版)

模块一:数据库连接池

php

namespace SplitDB;

use PDO;

class DBFactory {

private static array $connections = [];

private const MAX_HANDLES = 8;

public static function getConnection(string

$path): PDO {

$key = md5(

path);if(isset(self::connections[

key])) { return self::connections[

key]; } if (count(self::connections) >= self::MAX_HANDLES) {

array_shift(self::

$connections);

}

$pdo = new PDO("sqlite:" .

$path, null, null, [

PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,

PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,

PDO::ATTR_PERSISTENT => false,

]);

$pdo->exec("

PRAGMA busy_timeout = 5000;

PRAGMA journal_mode = WAL;

PRAGMA synchronous = NORMAL;

PRAGMA cache_size = -20000;

PRAGMA temp_store = MEMORY;

PRAGMA foreign_keys = OFF;

");

self::connections[key] =

$pdo;

return $pdo;

}

}

模块二:全局 ID 生成器(带重试机制)

php

namespace SplitDB;

use PDO;

use PDOException;

class IDGenerator {

public static function nextId(string $table, PDO $db, int

$maxRetries = 3): int {

$attempts = 0;

while ($attempts < $maxRetries) {

try {

$stmt = $db->prepare("

UPDATE id_generator

SET last_id = last_id + 1

WHERE table_name = :table

RETURNING last_id

");

$stmt->execute([':table' => $table]);

return (int)

$stmt->fetchColumn();

} catch (PDOException $e) {

attempts++;if(attempts >= $maxRetries) throw $e;

usleep (rand (5000, 20000));

}

}

throw new \RuntimeException ("Failed to generate ID after {$maxRetries} attempts.");

}

}

模块三:原子写外置文件

php

namespace SplitDB;

class ExternStorage {

public static function write(string $baseDir, int $topicId, int $bucketId, string $content): string {

$year = date('Y');

$quarter = 'Q' . ceil(date('n') / 3);

relDir = "extern/{year}/{quarter}/{bucketId}";

fullDir=rtrim(baseDir, '/') . '/' .

relDir;if(!isd​ir(fullDir)) mkdir($fullDir, 0755, true);

filePath = "{fullDir}/{

$topicId}.txt";

$tmpPath = "{

filePath}." . uniqid('', true) . '.tmp'; file_put_contents(tmpPath,

content,LOCKE​X);rename(tmpPath, $filePath);

if (function_exists('apcu_store')) {

apcu_store("extern_{$topicId}", $content, 600);

}

return "{relDir}/{topicId}.txt";

}

public static function read(string $baseDir, string $relPath, int

$topicId): string {

if (function_exists('apcu_fetch')) {

$cached = apcu_fetch("extern_{$topicId}", $success);

if ($success) return $cached;

}

fullPath=rtrim(baseDir, '/') . '/' .

relPath;if(!filee​xists(fullPath)) return '';

content=fileg​etc​ontents(fullPath);

if (function_exists('apcu_store') &&

content !== false) { apcu_store("extern_{topicId}",

$content, 600);

}

return $content ?: '';

}

}

模块四:队列消费者(CAS 乐观锁 + 防挂起)

php

namespace SplitDB;

use PDO;

class QueueConsumer {

private PDO

$db;

private int $timeout = 300;

public function __construct(PDO

$db) {

$this->db = $db;

}

public function consume(): void {

$this->recoverTimeoutTasks();

while (true) {

$task =

this−>fetchTask();if(!task) { sleep(1); continue; }

this−>processTask(task);

}

}

private function fetchTask(): ?array {

$row = $this->db->query("

SELECT id, type, data FROM task_queue

WHERE status = 'pending'

LIMIT 1

")->fetch();

if (!$row) return null;

$stmt = $this->db->prepare("

UPDATE task_queue

SET status = 'processing', update_time = strftime('%s', 'now')

WHERE id = :id AND status = 'pending'

");

$stmt->execute([':id' => $row['id']]);

// CAS 核心:必须校验是否真正抢占成功

if (

$stmt->rowCount () === 0) {

return null;

}

return $row;

}

private function processTask (array

$task): void {

try {

// 业务逻辑...

$this->db->prepare ("UPDATE task_queue SET status = 'done' WHERE id = :id")

->execute ([':id' =>

$task ['id']]);

} catch (\Throwable $e) {

// 异常时不标记失败,等待超时后自动恢复

}

}

private function recoverTimeoutTasks(): void {

$this->db->prepare("

UPDATE task_queue

SET status = 'pending', retry_count = retry_count + 1

WHERE status = 'processing'

AND update_time < strftime('%s', 'now') - :timeout

AND retry_count < 3

")->execute([':timeout' => $this->timeout]);

$this->db->prepare("

UPDATE task_queue

SET status = 'failed'

WHERE status = 'processing'

AND update_time < strftime('%s', 'now') - :timeout

AND retry_count >= 3

")->execute([':timeout' => $this->timeout]);

}

}

模块五:浏览量防刷刷盘(APCuIterator)

php

namespace SplitDB;

use PDO;

class ViewCounter {

public static function inc(int

topicId): void { apcu_inc("topic_views_{topicId}", 1);

}

public static function flush(PDO $db): void {

if (!class_exists('APCUIterator')) return;

/', APC_ITER_KEY | APC_ITER_VALUE);

db->beginTransaction(); try { foreach (iterator as

$item) {

$key =

$item['key'];

$views = (int)

item[′value′];if(views <= 0) continue;

id=(int)substr(key, 12);

apcu_dec($key, $views);

db->exec("UPDATE topic_index SET view_count = view_count + {views} WHERE id = {

$id}");

}

$db->commit();

} catch (\Throwable

$e) {

$db->rollBack();

throw $e;

}

}

}

十一、WAL 回收脚本 & 定时任务

WAL 回收脚本 splitdb_wal_reclaim.sh

bash

#!/bin/bash

supervisorctl stop splitdb-worker:*

sleep 2

find /www/splitdb/data -name ".sqlite" -exec sqlite3 {} "PRAGMA wal_checkpoint(TRUNCATE);" ;

supervisorctl start splitdb-worker:

Crontab 定时配置

bash

0 4 * /usr/local/bin/splitdb_wal_reclaim.sh >> /var/log/splitdb_wal.log 2>&1

0 3 * php /www/splitdb/cli/audit.php >> /var/log/splitdb_audit.log 2>&1

0 2 1 php /www/splitdb/cli/archive.php >> /var/log/splitdb_archive.log 2>&1

0 5 0 php /www/splitdb/cli/rebuild_search.php >> /var/log/splitdb_search.log 2>&1

十二、开发红线(强制遵守)

❌ 禁止前台实时跨桶统计

❌ 禁止网页同步更新索引

❌ 禁止全局 LIKE 遍历桶文件

❌ 禁止长事务、长快照

❌ 禁止高峰期 truncate WAL

❌ 禁止开启 PDO 持久连接

❌ 禁止依赖 Bucket 分片的自增 ID

❌ 禁止 HTTP 线程实时更新浏览量

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 4 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录