第四章 iOS 平台 SQLite 集成方案对比
4.1 原生 sqlite3 桥接与 FMDB 封装
iOS 平台下使用 SQLite 的两种主流方案分别是 原生 C 语言 sqlite3 桥接 和 FMDB 封装库。前者直接调用 sqlite3 公开 API,后者基于 Objective-C 运行时对底层做了面向对象封装。下表从集成方式、内存管理、代码量三个维度做了详细对比。
| 对比维度 | 原生 sqlite3 桥接 | FMDB 封装 |
|---|---|---|
| 集成方式 | 系统库 libsqlite3.tbd,无需额外依赖 | CocoaPods / SPM / 手动导入源码 |
| API 风格 | C 函数,sqlite3_open / sqlite3_prepare / sqlite3_step 等 | Objective-C 对象,FMDatabase / FMResultSet |
| 内存管理 | 需手动释放 stmt,极易出现内存泄漏 | ARC 自动管理,内部封装了释放逻辑 |
| 错误处理 | 每次调用检查返回值,代码冗余 | 提供 lastError / lastErrorCode 统一获取 |
| 类型映射 | 手动绑定 TEXT / INTEGER / REAL / BLOB | 自动识别 NSNumber / NSString / NSData / NSDate |
| 代码量(相同业务) | 约 3 倍于 FMDB | 简洁,1/3 行数即可完成 |
实践经验:在团队协作项目中,优先选择 FMDB;仅当对 SQLite 性能有极致要求(如高频写入 > 10k TPS)且团队精通 C 内存管理时,才考虑原生桥接。
4.1.1 原生 sqlite3 核心操作示例
// 打开数据库
sqlite3 *db;
if (sqlite3_open([dbPath UTF8String], &db) != SQLITE_OK) {
NSLog(@"打开失败:%s", sqlite3_errmsg(db));
return;
}
// 编译 SQL
const char *sql = "INSERT INTO user (id, name) VALUES (?, ?)";
sqlite3_stmt *stmt;
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) {
NSLog(@"编译失败:%s", sqlite3_errmsg(db));
sqlite3_close(db);
return;
}
// 绑定参数 & 执行
sqlite3_bind_int(stmt, 1, 1);
sqlite3_bind_text(stmt, 2, "Alice", -1, SQLITE_TRANSIENT);
if (sqlite3_step(stmt) != SQLITE_DONE) {
NSLog(@"执行失败:%s", sqlite3_errmsg(db));
}
// 手动释放
sqlite3_finalize(stmt);
sqlite3_close(db);
4.1.2 FMDB 封装等效操作
FMDatabase *db = [FMDatabase databaseWithPath:dbPath];
if (![db open]) {
NSLog(@"打开失败:%@", [db lastError]);
return;
}
BOOL success = [db executeUpdate:@"INSERT INTO user (id, name) VALUES (?, ?)",
@1, @"Alice"];
if (!success) {
NSLog(@"插入失败:%@", [db lastError]);
}
[db close];
FMDB 内部将
sqlite3_prepare_v2 → sqlite3_bind_* → sqlite3_step → sqlite3_finalize 封装在 executeUpdate: 方法中,自动处理类型推断和 stmt 生命周期,减少约 60% 的样板代码。4.2 FMDatabaseQueue 线程安全机制
FMDatabase 实例本身并非线程安全。多个线程同时调用同一个 FMDatabase 实例的 executeUpdate: 方法会导致 database is locked 崩溃或数据损坏。FMDatabaseQueue 通过串行 GCD 队列解决了该问题。
// 创建队列(串行)
FMDatabaseQueue *queue = [FMDatabaseQueue databaseQueueWithPath:dbPath];
// 在队列中执行所有操作
[queue inDatabase:^(FMDatabase *db) {
[db executeUpdate:@"INSERT INTO user (id, name) VALUES (?, ?)", @1, @"Alice"];
FMResultSet *rs = [db executeQuery:@"SELECT * FROM user WHERE id = ?", @1];
while ([rs next]) {
NSLog(@"name: %@", [rs stringForColumn:@"name"]);
}
}];
// 事务模式使用 inTransaction
[queue inTransaction:^(FMDatabase *db, BOOL *rollback) {
BOOL ok1 = [db executeUpdate:@"UPDATE user SET name = ? WHERE id = ?", @"Bob", @1];
BOOL ok2 = [db executeUpdate:@"UPDATE user SET name = ? WHERE id = ?", @"Charlie", @2];
if (!ok1 || !ok2) {
*rollback = YES;
return;
}
}];
| 方案 | 线程安全机制 | 性能损耗 | 适用场景 |
|---|---|---|---|
| FMDatabase + 串行队列 | 手动创建 serial_queue,所有操作 dispatch_sync | 中等 | 简单读多写少 |
| FMDatabaseQueue | 内置串行 GCD + 递归锁保护 | 低 | 标准读写混合 |
| FMDatabasePool(已弃用) | 连接池 + 互斥锁 | 高,死锁风险 | 不推荐使用 |
常见错误:在
inDatabase: 回调外使用 FMResultSet。队列内部会在回调结束时关闭 resultSet,外部访问野指针会导致 EXC_BAD_ACCESS。务必在 block 内完成所有读取操作。4.3 数据库迁移脚本执行策略
App 版本迭代中数据库字段变更不可避免。FMDB 配合版本号 + 迁移脚本表(schema_version)是一种轻量可靠的方案。
4.3.1 迁移表结构
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
4.3.2 迁移执行流程
- 从
schema_version表读取当前版本号,若表不存在则视为版本 0。 - 按版本号顺序加载
v1→v2、v2→v3…… 的迁移 SQL 脚本。 - 每条迁移脚本包裹在
inTransaction:中执行。 - 脚本执行成功后插入一条版本记录
INSERT INTO schema_version (version) VALUES (?);。 - 若某条脚本失败,
*rollback = YES回滚整个事务,版本号不回退。
- (void)migrateIfNeededWithQueue:(FMDatabaseQueue *)queue {
[queue inTransaction:^(FMDatabase *db, BOOL *rollback) {
// 获取当前版本
int currentVersion = 0;
FMResultSet *rs = [db executeQuery:@"SELECT MAX(version) FROM schema_version"];
if ([rs next]) {
currentVersion = [rs intForColumnIndex:0];
}
[rs close];
// 按顺序执行迁移脚本
NSArray *scripts = [self migrationScripts]; // v1→v2, v2→v3, ...
for (int i = currentVersion + 1; i <= scripts.count; i++) {
NSString *sql = scripts[i - 1];
BOOL ok = [db executeUpdate:sql];
if (!ok) {
NSLog(@"迁移 v%d 失败:%@", i, [db lastError]);
*rollback = YES;
return;
}
// 记录已迁移版本
[db executeUpdate:@"INSERT INTO schema_version (version) VALUES (?)", @(i)];
}
}];
}
推荐将每条迁移脚本保存为独立
.sql 文件,捆绑在 App Bundle 中。文件命名规则 migration_001_add_age.sql、migration_002_create_index.sql,便于按字母序加载。4.4 性能对比与选型建议
下图展示了原生 sqlite3、FMDB(单线程)、FMDatabaseQueue(多线程)在 10 万条写入场景下的耗时对比和线程安全边界。
| 集成方案 | 写入性能(10万条) | 线程安全 | 易用性 | 推荐场景 |
|---|---|---|---|---|
| 原生 sqlite3 | 2.1s(最快) | 需手动加锁 | ★☆☆☆☆ | 极度性能敏感、系统库依赖受限 |
| FMDB(单线程) | 2.8s(+33%) | 不安全 | ★★★★★ | 快速原型、小数据量、单线程环境 |
| FMDatabaseQueue | 3.3s(+57%) | 安全 | ★★★★☆ | 生产环境、多线程 App、迁移脚本 |
稳定性红线:在 iOS 主线程同步调用
FMDatabaseQueue 的 inDatabase: 会导致主线程阻塞,引发 watchdog 超时。请始终将数据库操作放在子线程:dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ ... });。4.5 常见错误与调试清单
- 错误 1 — database is locked
原因:多线程同时写 FMDatabase 实例。
解决:替换为 FMDatabaseQueue,所有操作在inDatabase:block 中执行。 - 错误 2 — 迁移脚本执行一半 App 崩溃
原因:迁移未包裹在事务中。
解决:使用inTransaction:,中途失败自动回滚整批迁移。 - 错误 3 — FMResultSet 在 block 外部使用
原因:队列内部在 block 结束后关闭了结果集。
解决:在 block 内将数据转为 NSDictionary 或 Model 对象再传出。 - 错误 4 — schema_version 表未创建导致迁移死循环
原因:首次迁移 SELECT MAX(version) 返回 0 但插入失败。
解决:先执行CREATE TABLE IF NOT EXISTS schema_version再读取。
调试技巧:在 FMDatabaseQueue 初始化后设置
queue.logsErrors = YES;,SQLite 错误会自动打印到控制台。配合 sqlite3_trace(db, callback, ref) 可追踪每条 SQL 执行耗时。