第四章 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 迁移执行流程

  1. 从 schema_version 表读取当前版本号,若表不存在则视为版本 0。
  2. 按版本号顺序加载 v1→v2、v2→v3…… 的迁移 SQL 脚本。
  3. 每条迁移脚本包裹在 inTransaction: 中执行。
  4. 脚本执行成功后插入一条版本记录 INSERT INTO schema_version (version) VALUES (?);。
  5. 若某条脚本失败,*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 万条写入场景下的耗时对比和线程安全边界。

iOS SQLite 集成方案性能对比(10万条写入) 原生 sqlite3 2.1s FMDB 单线程 2.8s FMDatabaseQueue 3.3s 线程安全边界 ❌ 不安全 ⚠ 不安全 ✅ 安全 🚫 不推荐 原生 sqlite3 FMDB 单线程 FMDatabaseQueue
集成方案写入性能(10万条)线程安全易用性推荐场景
原生 sqlite32.1s(最快)需手动加锁★☆☆☆☆极度性能敏感、系统库依赖受限
FMDB(单线程)2.8s(+33%)不安全★★★★★快速原型、小数据量、单线程环境
FMDatabaseQueue3.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 执行耗时。