SQLite三種分片策略的深度解析
SQLite 分片方案實(shí)戰(zhàn):三種分片策略的深度對(duì)比
當(dāng)單文件 SQLite 遇到并發(fā)瓶頸,我們?cè)撊绾纹凭??本文分?HagiCode 項(xiàng)目中三種不同場(chǎng)景下的 SQLite 分片方案,幫你理解如何選擇合適的分片策略。
全民制作人們大家好,我是 HagiCode 制作人俞坤。
背景
在構(gòu)建高性能應(yīng)用時(shí),單文件 SQLite 數(shù)據(jù)庫(kù)會(huì)碰到很現(xiàn)實(shí)的問(wèn)題。用戶(hù)量和數(shù)據(jù)量一上來(lái),這些狀況就會(huì)排隊(duì)找上門(mén):
- 寫(xiě)入操作開(kāi)始排隊(duì),響應(yīng)時(shí)間肉眼可見(jiàn)地變長(zhǎng)
- 查詢(xún)性能隨數(shù)據(jù)增長(zhǎng)往下掉
- 多線程訪問(wèn)時(shí)頻繁出現(xiàn) "database is locked" 錯(cuò)誤
很多人第一反應(yīng)是:要不要直接遷移到 PostgreSQL 或者 MySQL?這波操作雖然能解決問(wèn)題,但部署復(fù)雜度會(huì)直線上升。有沒(méi)有更輕量的方案?
答案是:分片。說(shuō)到底,工程問(wèn)題還是要回到工程方法里解決,通過(guò)將數(shù)據(jù)分散到多個(gè) SQLite 文件,可以顯著提升并發(fā)能力和查詢(xún)性能,同時(shí)保持 SQLite 的輕量級(jí)特性。
關(guān)于 HagiCode
本文分享的方案來(lái)自我們?cè)?HagiCode 項(xiàng)目中的實(shí)踐經(jīng)驗(yàn)。作為一個(gè) AI 代碼助手項(xiàng)目,HagiCode 需要處理大量的對(duì)話消息、狀態(tài)持久化和事件歷史記錄。正是在解決這些實(shí)際問(wèn)題的過(guò)程中,我們總結(jié)出了三種不同場(chǎng)景下的分片方案。
工欲善其事,必先利其器,但這些"器"怎么用,還得看具體的"事"是什么。
我們的代碼倉(cāng)庫(kù)在 github.com/HagiCode-org/site,歡迎感興趣的朋友深入了解。
三種分片方案概覽
經(jīng)過(guò)對(duì) HagiCode 代碼庫(kù)的分析,我們發(fā)現(xiàn)了三種針對(duì)不同業(yè)務(wù)場(chǎng)景的 SQLite 分片方案:
- Session Message 分片存儲(chǔ):AI 對(duì)話消息存儲(chǔ),特點(diǎn)是高頻寫(xiě)入、基于 Session 的隔離查詢(xún)
- Orleans Grain 分片存儲(chǔ):分布式框架狀態(tài)持久化,特點(diǎn)是跨節(jié)點(diǎn)訪問(wèn)、需要確定性路由
- Hero History 分片存儲(chǔ):游戲化系統(tǒng)歷史事件記錄,特點(diǎn)是事件溯源、需要遷移兼容
雖然業(yè)務(wù)場(chǎng)景不同,但三者都遵循相同的核心設(shè)計(jì)原則:
- 確定性路由:直接從業(yè)務(wù) ID 計(jì)算分片,無(wú)需元數(shù)據(jù)表
- 透明訪問(wèn):上層通過(guò)統(tǒng)一接口操作,不感知分片存在
- 獨(dú)立存儲(chǔ):每個(gè)分片是完全獨(dú)立的 SQLite 文件
- 并發(fā)優(yōu)化:WAL 模式 + busy_timeout 降低鎖競(jìng)爭(zhēng)
很多人會(huì)問(wèn):為什么不搞一套通用的分片方案?這個(gè)問(wèn)題問(wèn)得很實(shí)在,我們直接上結(jié)論:工程上沒(méi)有萬(wàn)能方案,只有最貼合當(dāng)前業(yè)務(wù)場(chǎng)景的方案。接下來(lái)我們深入對(duì)比這三種方案的具體實(shí)現(xiàn)。
分片策略對(duì)比
分片數(shù)量與命名規(guī)則
| 方面 | Session Message | Orleans Grain | Hero History |
|---|---|---|---|
| 分片數(shù)量 | 256 (16²) | 100 | 10 |
| 命名規(guī)則 | 16 進(jìn)制 (00-ff) | 10 進(jìn)制 (00-99) | 10 進(jìn)制 (0-9) |
| 存儲(chǔ)目錄 | DataDir/messages/ | DataDir/orleans/grains/ | DataDir/hero-history/ |
| 文件名模式 | {shard}.db | grains-{shard}.db | {shard}.db |
為什么分片數(shù)量差異這么大?這取決于業(yè)務(wù)特點(diǎn)。換句話說(shuō),模型會(huì)說(shuō),工具會(huì)變,工作流會(huì)升級(jí),但工程上的基本盤(pán)一直都在那里:你得先搞清楚自己要解決什么問(wèn)題。
- Session Message 使用 256 個(gè)分片,因?yàn)閷?duì)話消息的寫(xiě)入頻率最高,需要更多的分片來(lái)分散負(fù)載
- Orleans Grain 使用 100 個(gè)分片,平衡了并發(fā)性能和管理復(fù)雜度
- Hero History 只用 10 個(gè)分片,因?yàn)闅v史事件寫(xiě)入頻率較低,且需要考慮遷移成本
路由算法差異
路由算法是分片方案的核心,決定了數(shù)據(jù)如何分布到各個(gè)分片。三種方案使用了不同的路由策略:
// Session Message: GUID 后兩位 16 進(jìn)制
var normalized = Guid.Parse(sessionId.Value).ToString("N").ToLowerInvariant();
return normalized[^2..]; // 取末兩位 16 進(jìn)制字符
// Orleans Grain: 提取數(shù)字后兩位取模
var digits = ExtractDigits(grainId); // 提取所有數(shù)字
var lastTwoDigits = (digits[^2] * 10) + digits[^1];
return lastTwoDigits % shardCount;
// Hero History: 末位字符 ASCII 值取模
return heroId[^1] % 10;設(shè)計(jì)思路解析:
- Session Message 的 ID 是 GUID,轉(zhuǎn)換為 16 進(jìn)制后取末兩位,可以得到均勻分布的 256 個(gè)分片
- Orleans Grain 的 ID 格式不統(tǒng)一,可能包含字母和數(shù)字,所以提取所有數(shù)字后取模
- Hero History 的 ID 是字符串,直接用末位字符的 ASCII 值取模,簡(jiǎn)單但分布可能不夠均勻
關(guān)鍵點(diǎn):無(wú)論使用哪種算法,都必須保證同一 ID 永遠(yuǎn)映射到同一分片。這是分布式系統(tǒng)中最基本的要求,否則會(huì)導(dǎo)致數(shù)據(jù)不一致。說(shuō)到底,路由不穩(wěn)定,一切努力都是零。
初始化策略差異
| 方面 | Session Message | Orleans Grain | Hero History |
|---|---|---|---|
| 初始化時(shí)機(jī) | 按需懶加載 | 啟動(dòng)時(shí)全量并行初始化 | 按需懶加載 |
| 并發(fā)控制 | Lazy 防重復(fù)初始化 | Parallel.ForEachAsync | Lazy 防重復(fù)初始化 |
為什么 Orleans Grain 選擇啟動(dòng)時(shí)全量初始化?
因?yàn)?Orleans 是分布式框架,Grain 可能被調(diào)度到任意節(jié)點(diǎn)。如果在運(yùn)行時(shí)才發(fā)現(xiàn)分片文件不存在,會(huì)導(dǎo)致請(qǐng)求失敗。啟動(dòng)時(shí)全量初始化雖然會(huì)延長(zhǎng)啟動(dòng)時(shí)間,但能確保運(yùn)行時(shí)的穩(wěn)定性。能跑起來(lái)只是開(kāi)始,能維護(hù)下去才算本事。
懶加載的優(yōu)勢(shì):
對(duì)于 Session Message 和 Hero History,使用懶加載可以減少啟動(dòng)時(shí)間,只有在真正需要訪問(wèn)某個(gè)分片時(shí)才創(chuàng)建文件和初始化 Schema。使用 Lazy<Task> 可以防止并發(fā)初始化時(shí)的競(jìng)態(tài)條件。這個(gè)設(shè)計(jì)看著簡(jiǎn)單,但在真實(shí)項(xiàng)目里能省掉很多不必要的麻煩。
Schema 設(shè)計(jì)特點(diǎn)
三種方案的 Schema 設(shè)計(jì)反映了各自的業(yè)務(wù)特點(diǎn):
Session Message:
- 支持 Event Sourcing 模式(事件表 + 快照表)
- 包含消息內(nèi)容塊子表(MessageContentBlocks)
- 具有壓縮和壓縮標(biāo)記字段,支持后續(xù)優(yōu)化
Orleans Grain:
- 最簡(jiǎn)設(shè)計(jì):?jiǎn)伪?GrainState
- JSON 序列化存儲(chǔ)狀態(tài)
- ETag 樂(lè)觀并發(fā)控制
Hero History:
- 時(shí)間線查詢(xún)優(yōu)化索引
- DedupeKey 唯一約束防重復(fù)
- 支持多種事件類(lèi)型和狀態(tài)
從這些設(shè)計(jì)中可以看出,Schema 設(shè)計(jì)應(yīng)該緊密貼合業(yè)務(wù)需求,而不是追求通用性。Orleans Grain 的簡(jiǎn)單設(shè)計(jì)正是因?yàn)樗恍枰鎯?chǔ)序列化后的狀態(tài),不需要復(fù)雜的查詢(xún)能力。這波不是玄學(xué),是工程。別急著把名字起得太大,先看看這東西能不能在團(tuán)隊(duì)里活過(guò)兩個(gè)迭代。
并發(fā)配置對(duì)比
三種方案都使用了相同的 SQLite 并發(fā)優(yōu)化配置:
PRAGMA journal_mode=WAL; -- 寫(xiě)前日志模式 PRAGMA synchronous=NORMAL; -- 降低持久化開(kāi)銷(xiāo) PRAGMA busy_timeout=5000; -- 5秒忙等待 PRAGMA foreign_keys=ON; -- 外鍵約束
WAL 模式的優(yōu)勢(shì):
傳統(tǒng)的回滾日志模式在寫(xiě)入時(shí)會(huì)產(chǎn)生鎖競(jìng)爭(zhēng),而 WAL 模式允許讀寫(xiě)并發(fā)進(jìn)行。這在大數(shù)據(jù)量場(chǎng)景下可以顯著提升性能。很多人不知道這個(gè)配置,其實(shí)它比你想的要重要得多。
synchronous=NORMAL 的權(quán)衡:
設(shè)置為 FULL 可以保證最高安全性,但會(huì)顯著降低性能。NORMAL 模式在安全性和性能之間取得了平衡,對(duì)于大多數(shù)應(yīng)用來(lái)說(shuō)是合適的選擇。這個(gè)配置不需要糾結(jié)太久,NORMAL 就夠了。
如何選擇分片策略
基于對(duì) HagiCode 三種方案的分析,我們可以總結(jié)出以下決策矩陣:
高吞吐量場(chǎng)景 → 更多分片(如 Message 用 256) 簡(jiǎn)單維護(hù)性 → 較少分片(如 Hero History 用 10) 數(shù)字 ID 為主 → 取模算法(Orleans Grain) GUID 為主 → 16 進(jìn)制后綴(Session Message) 字符串 ID → ASCII 取模(Hero History)
分片數(shù)量選擇的經(jīng)驗(yàn)值:
- 太少(< 10):并發(fā)提升有限,分片意義不大
- 太多(> 1000):文件管理復(fù)雜,連接池開(kāi)銷(xiāo)大
- 經(jīng)驗(yàn)值:10-100 個(gè)分片適用于大多數(shù)場(chǎng)景
- 極高并發(fā)場(chǎng)景:可以考慮 256 個(gè)分片
這事你要是只看演示,確實(shí)容易上頭;可一旦進(jìn)了生產(chǎn)環(huán)境,賬就得一筆一筆算清楚。很多問(wèn)題不是不能做,只是沒(méi)把代價(jià)算明白。
實(shí)踐指南
實(shí)現(xiàn)標(biāo)準(zhǔn)化分片路由器
public interface IShardResolver<TId>
{
string ResolveShardKey(TId id);
}
// 16 進(jìn)制分片(適用于 GUID)
public class HexSuffixShardResolver : IShardResolver<string>
{
private readonly int _suffixLength;
public HexSuffixShardResolver(int suffixLength = 2)
{
_suffixLength = suffixLength;
}
public string ResolveShardKey(string id)
{
var normalized = id.Replace("-", "").ToLowerInvariant();
return normalized[^_suffixLength..];
}
}
// 數(shù)字取模分片(適用于純數(shù)字 ID)
public class NumericModuloShardResolver : IShardResolver<long>
{
private readonly int _shardCount;
public NumericModuloShardResolver(int shardCount)
{
_shardCount = shardCount;
}
public string ResolveShardKey(long id)
{
return (id % _shardCount).ToString("D2");
}
}統(tǒng)一連接工廠模式
public class ShardedConnectionFactory<TOptions>
{
private readonly ConcurrentDictionary<string, Lazy<Task>> _initializationTasks = new();
private readonly TOptions _options;
private readonly IShardSchemaInitializer _initializer;
public ShardedConnectionFactory(
TOptions options,
IShardSchemaInitializer initializer)
{
_options = options;
_initializer = initializer;
}
public async Task<TDbContext> CreateAsync(string shardKey, CancellationToken ct)
{
var connectionString = BuildConnectionString(shardKey);
// 使用 Lazy<Task> 防止并發(fā)初始化
var initTask = _initializationTasks.GetOrAdd(
connectionString,
_ => new Lazy<Task>(() => InitializeShardAsync(connectionString, ct))
);
await initTask.Value;
return CreateDbContext(connectionString);
}
private async Task InitializeShardAsync(string connectionString, CancellationToken ct)
{
await _initializer.InitializeAsync(connectionString, ct);
}
private string BuildConnectionString(string shardKey)
{
var shardPath = Path.Combine(_options.BaseDirectory, $"{shardKey}.db");
return $"Data Source={shardPath}";
}
private TDbContext CreateDbContext(string connectionString)
{
// 根據(jù)具體的 ORM 創(chuàng)建 DbContext
return Activator.CreateInstance(typeof(TDbContext), connectionString) as TDbContext;
}
}Schema 初始化最佳實(shí)踐
public class SqliteShardInitializer : IShardSchemaInitializer
{
public async Task InitializeAsync(string connectionString, CancellationToken ct)
{
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(ct);
// 并發(fā)優(yōu)化配置
await connection.ExecuteAsync("""
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA busy_timeout=5000;
PRAGMA foreign_keys=ON;
""");
// 創(chuàng)建表結(jié)構(gòu)
await connection.ExecuteAsync("""
CREATE TABLE IF NOT EXISTS Entities (
Id TEXT PRIMARY KEY,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
Data TEXT NOT NULL,
ETag TEXT
);
""");
// 創(chuàng)建索引
await connection.ExecuteAsync("""
CREATE INDEX IF NOT EXISTS IX_Entities_CreatedAt
ON Entities(CreatedAt DESC);
CREATE INDEX IF NOT EXISTS IX_Entities_UpdatedAt
ON Entities(UpdatedAt DESC);
""");
}
}關(guān)鍵注意事項(xiàng)
1. 路由穩(wěn)定性
路由算法必須保證同一 ID 永遠(yuǎn)映射到同一分片。避免使用隨機(jī)或時(shí)間相關(guān)的計(jì)算,也不要在算法中引入可變參數(shù)。
2. 分片數(shù)量選擇
分片數(shù)量應(yīng)該在設(shè)計(jì)階段確定,后期修改非常困難。需要考慮:
- 當(dāng)前和未來(lái)的并發(fā)量
- 單個(gè)分片的管理成本
- 數(shù)據(jù)遷移的復(fù)雜度
3. 遷移考慮
Hero History 方案展示了完整的遷移路徑:
- 新建分片存儲(chǔ)基礎(chǔ)設(shè)施
- 實(shí)現(xiàn)遷移服務(wù)將主庫(kù)數(shù)據(jù)復(fù)制到分片
- 驗(yàn)證遷移后查詢(xún)兼容性
- 切換讀寫(xiě)路徑到分片
- 清理主庫(kù)舊表
設(shè)計(jì)分片方案時(shí)就需要考慮未來(lái)的遷移需求。Talk is cheap. Show me the code,但光有代碼還不夠,你還得有完整的遷移路徑。一次成功不叫體系,持續(xù)成功才叫體系。
4. 監(jiān)控與運(yùn)維
- 監(jiān)控各分片的大小分布,及時(shí)發(fā)現(xiàn)數(shù)據(jù)傾斜
- 設(shè)置告警檢測(cè)分片熱點(diǎn),避免單個(gè)分片成為瓶頸
- 定期檢查 WAL 文件大小,防止磁盤(pán)空間占用過(guò)多
- 建立分片健康檢查機(jī)制
5. 測(cè)試覆蓋
- 測(cè)試邊界條件(空 ID、特殊字符、超長(zhǎng) ID)
- 驗(yàn)證路由確定性,確保同一 ID 總是映射到同一分片
- 并發(fā)寫(xiě)入壓力測(cè)試,驗(yàn)證鎖競(jìng)爭(zhēng)得到有效緩解
- 遷移測(cè)試,確保數(shù)據(jù)完整性和一致性
總結(jié)
通過(guò)對(duì)比 HagiCode 項(xiàng)目中的三種 SQLite 分片方案,我們可以看到:
- 沒(méi)有萬(wàn)能的解決方案:不同業(yè)務(wù)場(chǎng)景需要不同的分片策略
- 核心原則是通用的:確定性路由、透明訪問(wèn)、獨(dú)立存儲(chǔ)、并發(fā)優(yōu)化
- 設(shè)計(jì)要面向未來(lái):考慮遷移路徑和運(yùn)維成本
如果你的項(xiàng)目正在使用 SQLite,并且開(kāi)始遇到并發(fā)瓶頸,希望這篇文章能為你提供一些思路。不需要急著遷移到重量級(jí)數(shù)據(jù)庫(kù),有時(shí)候合適的分片方案就能解決問(wèn)題。
當(dāng)然,分片不是銀彈。在選擇分片方案之前,先確保:
- 你已經(jīng)優(yōu)化了單表查詢(xún)性能
- 你已經(jīng)使用了合適的索引
- 你已經(jīng)啟用了 WAL 模式
只有在這些優(yōu)化都做完之后,仍然存在性能瓶頸時(shí),才考慮引入分片。你能把簡(jiǎn)單的事情做好,這本身就是一種能力。
很多話講一遍不如做一遍,接下來(lái)就讓工程結(jié)果自己發(fā)聲。
參考資料
- HagiCode 項(xiàng)目倉(cāng)庫(kù):github.com/HagiCode-org/site
- SQLite WAL 模式文檔:sqlite.org/wal.html
- Orleans 分布式框架:dotnet.github.io/orleans
到此這篇關(guān)于SQLite三種分片策略的深度解析的文章就介紹到這了,更多相關(guān)SQLite 分片策略?xún)?nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
數(shù)據(jù)庫(kù)的ACID特性術(shù)語(yǔ)詳解
這篇文章主要介紹了數(shù)據(jù)庫(kù)的ACID特性術(shù)語(yǔ)詳解,ACID就是:原子性(Atomicity )、一致性( Consistency )、隔離性( Isolation)和持久性(Durabilily),本文分別解釋了它們,需要的朋友可以參考下2015-02-02
dataGrip顯示clickhouse時(shí)間字段不正確的問(wèn)題
最近做數(shù)據(jù)遷移碰到一個(gè)問(wèn)題,源數(shù)據(jù)和目的端數(shù)據(jù),導(dǎo)入的時(shí)間怎么都差8個(gè)小時(shí),本文就來(lái)介紹一下如何解決,感興趣的可以了解一下2021-09-09
RBAC簡(jiǎn)介_(kāi)動(dòng)力節(jié)點(diǎn)Java學(xué)院整理
這篇文章主要介紹了RBAC簡(jiǎn)介,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-08-08
Navicat premium連接數(shù)據(jù)庫(kù)出現(xiàn):2003 Can''t connect to MySQL server o
這篇文章主要介紹了Navicat premium連接數(shù)據(jù)庫(kù)出現(xiàn):2003 - Can't connect to MySQL server on 'localhost' (10061 "Unknown error")的問(wèn)題,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-11-11
Windows10用Navicat?定時(shí)備份報(bào)錯(cuò)80070057的問(wèn)題解析
這篇文章主要介紹了Windows10用Navicat?定時(shí)備份報(bào)錯(cuò)80070057的問(wèn)題,本文通過(guò)圖文并茂的形式給大家分享問(wèn)題所在原因及解決方案,需要的朋友可以參考下2023-10-10

