SQLite在iOS端承担了绝大部分本地结构化数据存储任务,但数据量增长后,查询缓慢、写入卡顿甚至数据库锁死会接连出现。这些症状的根源通常不在SQLite内核,而在索引设计、连接生命周期和多线程访问控制上。索引决定查询计划是否走全表扫描,连接管理和队列设计决定并发下是否存在数据竞争或锁冲突。下面先定位查询慢的原因,再用FMDB建立稳定封装,最后通过串行队列与WAL模式完成读写安全设计。

一、SQLite索引优化:从执行计划到复合索引
SQLite默认使用B-tree组织索引,普通索引在等值查询和范围查询中都能显著减少扫描行数。但索引并非越多越好,每个二级索引都会增加写入时的维护成本,并且占用额外磁盘空间。优化之前,应该先用EXPLAIN QUERY PLAN查看SQLite实际选择的查询策略。比如执行EXPLAIN QUERY PLAN SELECT * FROM message WHERE user_id = 1024 AND status = 1 ORDER BY create_time DESC,如果输出中包含SCAN TABLE message,就说明没有命中合适索引,此时数据库只能逐行扫描。
针对上面这个查询,单独给user_id建索引只能过滤一部分行,status和排序仍然需要额外排序或二次过滤。更合理的做法是建立复合索引CREATE INDEX idx_msg_user_status_time ON message(user_id, status, create_time DESC)。复合索引遵循最左前缀原则,查询条件中只要包含从最左列开始的连续列,就能利用该索引。上面三条列都出现在查询中,并且顺序与索引一致,SQLite可以直接通过索引完成过滤和排序,避免Using temporary和filesort。对于只需要user_id和status的查询,同样可以复用该索引,因为最左两列满足条件。
另外,覆盖索引是提升读性能的有效手段。如果查询只需要索引列的值,SQLite可以只访问索引B-tree而不回表。比如SELECT user_id, status FROM message WHERE user_id = 1024如果索引包含这两列,就可以形成覆盖索引。建索引时要结合真实查询频率和条件组合,避免为每个独立字段建单列索引。过多单列索引不仅降低写入速度,还可能导致优化器选错索引。日常可以用EXPLAIN QUERY PLAN验证关键SQL是否在预期计划中,并在数据分布变化后重新评估。
二、FMDB封装实践:连接、事务与参数绑定
FMDB是对SQLite C API的Objective-C封装,核心类是FMDatabase和FMDatabaseQueue。直接使用FMDatabase时,每个线程必须持有独立实例,否则会因SQLite的连接线程限制引发崩溃或数据损坏。更稳妥的做法是全局使用FMDatabaseQueue,它内部维护一个GCD串行队列,所有数据库操作都通过block提交到该队列执行,从根源上避免了跨线程并发访问同一个连接的问题。封装时建议把数据库文件的创建、打开、建表、升级统一收敛到一个管理类中,对外只暴露业务方法。
参数绑定是防止SQL注入和提升执行效率的关键。FMDB的executeQuery:withArgumentsInArray:和executeUpdate:withArgumentsInArray:会使用SQLite的预编译语句,参数以?占位符传入,数据库只需要编译一次SQL,后续替换参数即可。不要在拼接字符串时直接把用户输入拼接进去,例如不要写NSString stringWithFormat: @"SELECT * FROM user WHERE name = '%@'"。下面给出一个基于FMDatabaseQueue的基础封装示例,包含建表、插入和查询。
@interface DBCore : NSObject
+ (instancetype)shared;
- (void)insertUser:(NSString *)name age:(NSInteger)age;
- (NSArray *)usersWithMinAge:(NSInteger)age;
@end
@implementation DBCore {
FMDatabaseQueue *_queue;
}
+ (instancetype)shared {
static DBCore *instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[DBCore alloc] init];
});
return instance;
}
- (instancetype)init {
self = [super init];
if (self) {
NSString *docPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject;
NSString *dbPath = [docPath stringByAppendingPathComponent:@"app.db"];
_queue = [FMDatabaseQueue databaseQueueWithPath:dbPath];
[_queue inDatabase:^(FMDatabase *db) {
BOOL ok = [db executeUpdate:@"CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER NOT NULL)"];
if (!ok) {
NSLog(@"create table failed: %@", db.lastErrorMessage);
}
}];
}
return self;
}
- (void)insertUser:(NSString *)name age:(NSInteger)age {
[_queue inDatabase:^(FMDatabase *db) {
BOOL ok = [db executeUpdate:@"INSERT INTO user (name, age) VALUES (?, ?)", name, @(age)];
if (!ok) {
NSLog(@"insert failed: %@", db.lastErrorMessage);
}
}];
}
- (NSArray *)usersWithMinAge:(NSInteger)age {
__block NSMutableArray *result = [NSMutableArray array];
[_queue inDatabase:^(FMDatabase *db) {
FMResultSet *rs = [db executeQuery:@"SELECT * FROM user WHERE age >= ?", @(age)];
while ([rs next]) {
NSDictionary *row = @{
@"id": @([rs intForColumn:@"id"]),
@"name": [rs stringForColumn:@"name"] ?: @"",
@"age": @([rs intForColumn:@"age"])
};
[result addObject:row];
}
[rs close];
}];
return result;
}
@end
上面的封装中,所有对_queue的操作都被约束在同一个串行队列中执行。即使外部多个线程同时调用insertUser和usersWithMinAge:,也不会出现FMDatabase实例跨线程读写的问题。需要注意,不要在inDatabase:的block内部再开启另一个inDatabase调用,这会导致死锁。批量操作时应当使用inTransaction:,它会自动包装BEGIN和COMMIT,某一步失败时还能回滚。
三、多线程读写安全队列设计:串行队列与WAL模式
SQLite默认的锁机制允许同一时刻只有一个写连接,读操作在旧版本中也可能与写操作互斥。如果多个线程同时写数据库,直接使用单个FMDatabase实例很容易触发SQLITE_BUSY错误。FMDatabaseQueue通过串行队列把所有操作排队,写操作串行化,解决了连接安全问题,但读操作也被迫串行,读多写少场景下可能成为瓶颈。为了提升并发读能力,可以开启SQLite的WAL模式。WAL模式将修改先写入单独的WAL文件,读事务不会阻塞写事务,写事务也不会阻塞读事务,实现读写并发。
开启WAL模式只需要在数据库初始化时执行PRAGMA journal_mode=WAL。FMDB中可以在inDatabase里设置,也可以通过FMDatabase的setShouldCacheStatements:提升重复SQL的执行效率。完整的初始化配置可以这样写:
[_queue inDatabase:^(FMDatabase *db) {
[db executeUpdate:@"PRAGMA journal_mode=WAL"];
[db executeUpdate:@"PRAGMA synchronous=NORMAL"];
[db setShouldCacheStatements:YES];
}];
WAL模式并不是完全免费的,它会生成-wal和-shm文件,需要确保这些文件和应用数据库一起备份或迁移。此外,WAL模式下写操作依然只能串行,因为SQLite同一时间只允许一个写事务,所以FMDatabaseQueue的串行队列仍然有必要。如果业务中读操作非常频繁,可以在串行队列之外再提供一个只读连接池,但需要考虑数据一致性。更简单的做法是维持单个FMDatabaseQueue,并配合WAL实现读不阻塞写、写不阻塞读,大多数iOS应用的读写压力已经足够应对。
队列的调用方式也影响线程表现。如果数据库操作涉及大量数据解析或UI无关计算,建议在dispatch_async中调用inDatabase:,避免阻塞调用者线程。返回结果需要切回主线程更新UI时,使用dispatch_async(dispatch_get_main_queue(), ^{ ... })。如果业务允许异步,还可以扩充一个异步接口,把block交给全局队列再进入数据库串行队列,减少对调用方的占用。
四、性能优化与常见坑:批量事务、缓存与版本迁移
批量写入是大数据量场景下的性能重灾区。如果每次插入都自动提交一次事务,SQLite需要频繁执行fsync持久化,性能可能下降数倍甚至数十倍。正确做法是把一批写入放在inTransaction:中,让所有INSERT共享一个事务,提交时只做一次刷盘。FMDB的inTransaction会自动处理BEGIN和COMMIT,当block返回YES时提交,返回NO时回滚。下面是一个批量插入一万条记录的优化示例。
- (void)batchInsertUsers:(NSArray<NSDictionary *> *)users {
[_queue inTransaction:^(FMDatabase *db, BOOL *rollback) {
for (NSDictionary *user in users) {
BOOL ok = [db executeUpdate:@"INSERT INTO user (name, age) VALUES (?, ?)",
user[@"name"], user[@"age"]];
if (!ok) {
*rollback = YES;
return;
}
}
}];
}
注意代码块中用了泛型NSArray<NSDictionary *>,在HTML pre内小于号和大于号需要转义,所以实际代码中写成了转义后的形式。这段示例在十万级批量插入时,比单条自动提交快一个数量级。
另一个容易忽略的坑是结果集没有关闭。FMDB的FMResultSet使用完必须调用close,否则会占用数据库资源,后续操作可能报“database is locked”。上面示例中在循环后调用[rs close]。对于频繁查询的静态数据,可以引入内存缓存,比如用NSCache保存热点数据,数据库只作为最终数据源,降低SQLite读压力。缓存需要考虑失效策略,增删改操作后及时清除对应缓存,否则会出现脏数据。
数据库版本迁移也经常引发问题。应用升级后表结构变更,如果直接修改建表语句,老用户升级时不会执行。推荐在初始化时读取PRAGMA user_version,根据版本号逐步执行变更脚本。每次升级只追加ALTER TABLE或CREATE TABLE操作,并用事务包裹。这样能避免首次安装和覆盖升级执行不同建表路径导致的字段缺失。最后,所有数据库操作应尽量避免在主线程执行,只有轻量级且数据量很小的查询可以放在主线程,否则卡顿会直接反映在UI交互上。
SQLite索引优化FMDB封装多线程读写安全修改时间:2026-10-03 03:00:08