数据持久化是移动端开发的核心能力之一。iOS提供了从轻量级KV存储到完整关系数据库的多层次方案。本文将从底层存储原理出发,系统性地梳理iOS中各类数据库与持久化技术的设计思想、实现机制和适用场景。

一、iOS数据持久化方案全景

iOS中常见的数据持久化方案,按数据复杂度和使用场景可分为以下几层:

graph TD
    subgraph KV["轻量级KV存储"]
        A["UserDefaults
plist序列化"] B["Keychain
加密存储"] C["MMKV
mmap + protobuf"] end subgraph FILE["文件存储"] D["plist / JSON"] E["NSCoding / Codable
归档"] end subgraph SQL["关系数据库"] F["SQLite
C语言API"] G["FMDB
OC封装"] H["WCDB
ORM + 加密"] end subgraph ORM["ORM框架"] I["Core Data
Apple官方"] J["Realm
零拷贝"] K["SwiftData
Swift原生"] end KV --> |"数据结构更复杂"| FILE FILE --> |"需要结构化查询"| SQL SQL --> |"需要对象映射"| ORM
方案数据模型查询能力线程安全加密适用场景
UserDefaultsKV (plist类型)Key查找线程安全用户偏好、简单配置
KeychainKV (Data)Key查找线程安全系统加密密码、Token、证书
MMKVKV (protobuf)Key查找线程安全可选AES高频读写的KV数据
plist/JSON文件字典/数组不安全静态配置、缓存
NSCoding/Codable对象图不安全对象序列化
SQLite关系表SQL需手动处理可选(SQLCipher)结构化数据、复杂查询
FMDB关系表SQLFMDatabaseQueue可选SQLite的OC封装
WCDB关系表+ORMSQL+ORM自动管理内置高性能结构化存储
Core Data对象图NSPredicateNSManagedObjectContext复杂对象关系
Realm对象类型安全查询对象冻结可选AES高性能对象存储
SwiftDataSwift对象#Predicate宏ModelContextSwift原生数据持久化

二、轻量级KV存储

2.1 UserDefaults

UserDefaults是iOS最常用的轻量级存储方案,底层将数据以plist格式序列化到磁盘。

存储机制

graph LR
    APP["App进程"] --> |读写| MEM["内存缓存
(NSDictionary)"] MEM --> |同步| PLIST["Library/Preferences/
bundleId.plist"]

启动时,系统将整个plist文件反序列化到内存中的一个NSDictionary。读操作直接访问内存字典,写操作先修改内存字典,再由系统择机将整个字典序列化写回磁盘。

支持的数据类型:仅支持Property List类型——NSDataNSStringNSNumberNSDateNSArrayNSDictionary。自定义对象需要先转换为Data(通过NSCodingCodable)。

// 基础类型
UserDefaults.standard.set(42, forKey: "launchCount")
UserDefaults.standard.set("zh-Hans", forKey: "language")
UserDefaults.standard.set(true, forKey: "hasOnboarded")

// Codable对象
let encoder = JSONEncoder()
if let data = try? encoder.encode(userProfile) {
    UserDefaults.standard.set(data, forKey: "userProfile")
}

性能特点

  • 首次访问时加载整个plist到内存,文件越大启动越慢
  • 读取O(1)(内存字典查找)
  • 写入时将整个字典重新序列化,单次写入的数据量与总数据量成正比
  • synchronize()在iOS 12+已不需要手动调用,系统会自动同步

适用边界:UserDefaults适合存储少量简单配置数据(几KB到几十KB)。当数据量超过几百KB,或需要频繁写入时,应考虑MMKV等替代方案。

2.2 Keychain

Keychain是iOS提供的系统级安全存储方案,数据受iOS Data Protection机制保护——系统使用设备唯一密钥(UID Key,烧录在芯片中)和用户密码派生的密钥构成分层加密体系,对Keychain条目进行AES加密。

核心特性

  • 数据存储在系统级的SQLite数据库中(/var/Keychains/keychain-2.db),由securityd守护进程管理
  • 支持Access Control——可要求生物认证(Face ID/Touch ID)才能读取,此时密钥操作由Secure Enclave执行
  • App卸载后Keychain数据默认保留(iOS会保留非ThisDeviceOnly的条目,重装App后仍可访问)
  • 通过Keychain Sharing可在同一开发者的不同App间共享数据
// 存储密码
func savePassword(_ password: String, for account: String) throws {
    let data = password.data(using: .utf8)!
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: account,
        kSecValueData as String: data,
        kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
    ]
    
    let status = SecItemAdd(query as CFDictionary, nil)
    if status == errSecDuplicateItem {
        let updateQuery: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account
        ]
        let attributes: [String: Any] = [kSecValueData as String: data]
        SecItemUpdate(updateQuery as CFDictionary, attributes as CFDictionary)
    } else if status != errSecSuccess {
        throw KeychainError.saveFailed(status)
    }
}

Keychain的保护级别

保护级别可访问时机适用场景
kSecAttrAccessibleWhenUnlocked设备解锁时默认值,大多数场景
kSecAttrAccessibleAfterFirstUnlock首次解锁后至重启前后台刷新需要的Token
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly设备设置了密码且解锁时高安全需求,密码移除则删除数据
kSecAttrAccessibleAlways任何时候(已废弃)不推荐使用

2.3 MMKV

MMKV是微信团队开源的高性能KV存储框架,底层基于mmap内存映射和protobuf序列化。

核心原理

graph LR
    APP["App进程
虚拟内存"] --> |mmap映射| FILE["磁盘文件
MMKV文件"] APP --> |写入| BUF["追加写入缓冲区
(protobuf编码)"] BUF --> |append| FILE FILE --> |进程崩溃| SAFE["数据已持久化
(内核负责刷盘)"]
  1. 使用mmap()将文件映射到进程的虚拟地址空间,写入内存即等同于写入文件
  2. 数据以protobuf格式编码,采用增量追加写入策略——新数据直接追加到文件末尾
  3. 当文件中同一个Key出现多次(旧值+新值),触发全量重写(CRC校验 + 去重压缩)

与UserDefaults性能对比

操作MMKVUserDefaults
写入1000次int~3ms~200ms
写入机制增量追加,O(1)全量序列化plist,O(N)
崩溃安全性mmap写入即持久化可能丢失未同步数据
多进程支持支持(文件锁+mmap)不支持
import MMKV

// 初始化
MMKV.initialize(rootDir: nil)
let mmkv = MMKV.default()!

// 基础类型
mmkv.set(42, forKey: "count")
mmkv.set("hello", forKey: "greeting")
mmkv.set(true, forKey: "flag")

// 读取
let count = mmkv.int32(forKey: "count")
let greeting = mmkv.string(forKey: "greeting")

// Codable对象
if let data = try? JSONEncoder().encode(user) {
    mmkv.set(data, forKey: "user")
}

MMKV适合替代UserDefaults处理高频读写的KV数据场景,如用户配置、AB实验参数、埋点缓存等。

三、SQLite——iOS数据库的基石

iOS中几乎所有的关系数据库方案(FMDB、WCDB、Core Data、SwiftData)底层都依赖SQLite。理解SQLite的工作原理,是理解整个iOS数据库生态的基础。

3.1 SQLite架构

SQLite是一个嵌入式的、零配置的、无服务器的关系数据库引擎,整个数据库存储在一个单独的磁盘文件中。

graph TD
    subgraph "SQLite架构"
        API["接口层
sqlite3_open, sqlite3_exec, ..."] subgraph "编译器" TOKENIZER["词法分析器"] PARSER["语法分析器"] CODEGEN["代码生成器"] end subgraph "虚拟机" VDBE["VDBE
虚拟数据库引擎
执行字节码"] end subgraph "后端" BTREE["B-Tree
数据组织"] PAGER["Pager
页面缓存与事务"] OS["OS接口层
文件I/O"] end end API --> TOKENIZER TOKENIZER --> PARSER PARSER --> CODEGEN CODEGEN --> VDBE VDBE --> BTREE BTREE --> PAGER PAGER --> OS

SQL语句的执行流程:

  1. 词法/语法分析:将SQL文本解析为抽象语法树(AST)
  2. 代码生成:将AST转换为VDBE字节码
  3. 虚拟机执行:VDBE逐条执行字节码指令
  4. B-Tree操作:数据以B-Tree结构组织,支持O(log N)的查找
  5. Pager层:管理页面缓存、处理事务(WAL/Journal模式)和锁机制
  6. OS接口:通过VFS(Virtual File System)抽象层执行实际的文件I/O

3.2 B-Tree与页面结构

SQLite将数据库文件划分为固定大小的页面(默认4096字节),每个表和索引对应一棵B-Tree(数据表使用B+Tree变体——叶子节点存储实际数据行)。

数据库文件结构:
┌──────────┐
│  Page 1  │  ← 文件头 + sqlite_master表的根页面
├──────────┤
│  Page 2  │  ← 某个表的B-Tree内部节点
├──────────┤
│  Page 3  │  ← 某个表的B-Tree叶子节点(存储实际数据行)
├──────────┤
│  Page 4  │  ← 某个索引的B-Tree节点
├──────────┤
│   ...    │
└──────────┘

B-Tree的查找过程:

查找 rowid = 42:

        [10, 30, 50]          ← 内部节点(只存键)
       /     |      \
  [1..9]  [11..29] [31..49] [51..60]  ← 叶子节点(存键+数据行)
                  42在这个范围,找到!

为什么选B-Tree而不是哈希表或平衡二叉树?

  • 哈希表不支持范围查询(WHERE age > 20 AND age < 30
  • 平衡二叉树每个节点只存一个键,树高更大,导致更多磁盘I/O
  • B-Tree每个节点存多个键,树高低(通常3-4层即可容纳百万级数据),每次磁盘I/O读取一整页可以比较多个键

3.3 WAL模式与并发

SQLite支持两种日志模式,对并发性能有重大影响。

回滚日志(Rollback Journal)模式(默认):

sequenceDiagram
    participant W as 写入者
    participant DB as 数据库文件
    participant J as 回滚日志
    
    W->>J: 1. 将原始页面复制到日志
    W->>DB: 2. 修改数据库页面
    W->>J: 3. 提交:删除日志
    Note over W,J: 崩溃恢复:用日志中的原始页面覆盖数据库

问题:写入时会阻塞所有读取,因为读取者可能读到写了一半的数据。

WAL(Write-Ahead Logging)模式

sequenceDiagram
    participant R as 读取者
    participant W as 写入者
    participant DB as 数据库文件
    participant WAL as WAL文件
    
    W->>WAL: 1. 将修改写入WAL文件
    R->>DB: 2. 读取原始数据库(快照隔离)
    Note over R,WAL: 读取者看到的是写入前的一致性快照
    W->>WAL: 3. 提交:WAL中标记提交点
    Note over DB,WAL: Checkpoint:将WAL中的修改合并回数据库文件

WAL模式的核心优势:读写可以并发执行,互不阻塞。读取者始终看到最近一次提交前的一致性快照。这是iOS开发中推荐的模式。

sqlite3_exec(db, "PRAGMA journal_mode=WAL;", nil, nil, nil)

但需要注意:WAL模式下仍然只允许一个写入者。多个写入者会串行等待。

3.4 SQLite在iOS中的直接使用

iOS系统自带SQLite的动态库(libsqlite3.tbd),可以直接使用C语言API。

import SQLite3

class SQLiteManager {
    private var db: OpaquePointer?
    
    func open(path: String) throws {
        guard sqlite3_open(path, &db) == SQLITE_OK else {
            let error = String(cString: sqlite3_errmsg(db))
            throw DatabaseError.openFailed(error)
        }
        sqlite3_exec(db, "PRAGMA journal_mode=WAL;", nil, nil, nil)
        sqlite3_exec(db, "PRAGMA foreign_keys=ON;", nil, nil, nil)
    }
    
    func createTable() throws {
        let sql = """
            CREATE TABLE IF NOT EXISTS users (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL,
                email TEXT UNIQUE,
                created_at REAL DEFAULT (julianday('now'))
            );
            """
        var errMsg: UnsafeMutablePointer<CChar>?
        guard sqlite3_exec(db, sql, nil, nil, &errMsg) == SQLITE_OK else {
            let error = String(cString: errMsg!)
            sqlite3_free(errMsg)
            throw DatabaseError.executionFailed(error)
        }
    }
    
    // 使用预编译语句(Prepared Statement)防止SQL注入并提升性能
    func insertUser(name: String, email: String) throws {
        let sql = "INSERT INTO users (name, email) VALUES (?, ?);"
        var stmt: OpaquePointer?
        
        guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
            throw DatabaseError.prepareFailed
        }
        defer { sqlite3_finalize(stmt) }
        
        sqlite3_bind_text(stmt, 1, (name as NSString).utf8String, -1, nil)
        sqlite3_bind_text(stmt, 2, (email as NSString).utf8String, -1, nil)
        
        guard sqlite3_step(stmt) == SQLITE_DONE else {
            throw DatabaseError.stepFailed
        }
    }
    
    func queryUsers() throws -> [(id: Int, name: String, email: String)] {
        let sql = "SELECT id, name, email FROM users ORDER BY id;"
        var stmt: OpaquePointer?
        
        guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
            throw DatabaseError.prepareFailed
        }
        defer { sqlite3_finalize(stmt) }
        
        var results: [(id: Int, name: String, email: String)] = []
        while sqlite3_step(stmt) == SQLITE_ROW {
            let id = Int(sqlite3_column_int(stmt, 0))
            let name = String(cString: sqlite3_column_text(stmt, 1))
            let email = String(cString: sqlite3_column_text(stmt, 2))
            results.append((id, name, email))
        }
        return results
    }
    
    deinit {
        sqlite3_close(db)
    }
}

直接使用SQLite C API的问题是代码冗长、类型不安全、容易出错(如忘记finalize)。这催生了各种封装层。

四、FMDB——SQLite的Objective-C封装

FMDB是iOS社区使用最广泛的SQLite封装库,由Gus Mueller编写。它的目标是将SQLite的C API封装为友好的Objective-C接口,同时不隐藏SQL本身。

4.1 核心类

classDiagram
    class FMDatabase {
        -sqlite3* db
        +open() Bool
        +executeUpdate(sql, values) Bool
        +executeQuery(sql, values) FMResultSet
        +beginTransaction()
        +commit()
        +rollback()
    }
    
    class FMResultSet {
        -sqlite3_stmt* statement
        +next() Bool
        +intForColumn(name) Int
        +stringForColumn(name) String
        +dataForColumn(name) Data
    }
    
    class FMDatabaseQueue {
        -FMDatabase db
        -dispatch_queue_t queue
        +inDatabase(block)
        +inTransaction(block)
    }
    
    class FMDatabasePool {
        -NSMutableArray databases
        +inDatabase(block)
    }
    
    FMDatabaseQueue --> FMDatabase
    FMDatabasePool --> FMDatabase
    FMDatabase --> FMResultSet
  • FMDatabase:对sqlite3*的封装,提供executeUpdate(增删改)和executeQuery(查)方法
  • FMResultSet:对sqlite3_stmt*的封装,提供按列名取值的方法
  • FMDatabaseQueue:线程安全的核心——内部使用串行队列保证所有数据库操作顺序执行
  • FMDatabasePool:数据库连接池,适用于读多写少的场景

4.2 线程安全策略

FMDB的FMDatabase本身不是线程安全的。在多线程环境下必须使用FMDatabaseQueue

FMDatabaseQueue *queue = [FMDatabaseQueue databaseQueueWithPath:dbPath];

// 线程安全的写入
[queue inTransaction:^(FMDatabase *db, BOOL *rollback) {
    BOOL success = [db executeUpdate:@"INSERT INTO users (name, email) VALUES (?, ?)",
                    @"张三", @"zhangsan@example.com"];
    if (!success) {
        *rollback = YES;
        return;
    }
    
    success = [db executeUpdate:@"INSERT INTO orders (user_id, amount) VALUES (?, ?)",
               @(db.lastInsertRowId), @(99.9)];
    if (!success) {
        *rollback = YES;
    }
}];

// 线程安全的查询
[queue inDatabase:^(FMDatabase *db) {
    FMResultSet *rs = [db executeQuery:@"SELECT * FROM users WHERE name = ?", @"张三"];
    while ([rs next]) {
        NSString *name = [rs stringForColumn:@"name"];
        NSString *email = [rs stringForColumn:@"email"];
        NSLog(@"%@ - %@", name, email);
    }
    [rs close];
}];

FMDatabaseQueue的实现原理:内部持有一个dispatch_queue_t串行队列和一个FMDatabase实例,所有的block都被派发到这个串行队列上执行,从而保证同一时刻只有一个线程访问数据库连接。

需要注意的一个陷阱:不要在inDatabase:inTransaction:的block内部再次调用inDatabase:,这会造成死锁(串行队列的同步调用自己)。

4.3 FMDB的局限

  • 没有ORM能力,需要手动编写SQL和手动映射对象
  • 错误处理依赖返回值检查(BOOL),容易被忽略
  • 不支持数据库加密(需要自行集成SQLCipher)
  • 不支持模型迁移管理

五、WCDB——微信的高性能数据库框架

WCDB是微信团队开源的移动端数据库框架,在SQLite基础上提供了ORM、加密(集成SQLCipher)、损坏修复和全文搜索等能力。

5.1 ORM映射

WCDB通过Swift的CodingKeys协议实现ORM映射,无需手动编写SQL建表语句:

import WCDBSwift

class User: TableCodable {
    var id: Int = 0
    var name: String = ""
    var email: String?
    var createdAt: Date = Date()
    
    enum CodingKeys: String, CodingTableKey {
        typealias Root = User
        
        case id
        case name
        case email
        case createdAt
        
        static let objectRelationalMapping = TableBinding(CodingKeys.self) {
            BindColumnConstraint(id, isPrimary: true, isAutoIncrement: true)
            BindColumnConstraint(name, isNotNull: true)
            BindIndex(email, namedWith: "idx_email", isUnique: true)
        }
    }
}

5.2 CRUD操作

WCDB的查询使用链式调用语法,提供类型安全的查询接口:

let database = Database(at: dbPath)

// 建表
try database.create(table: "users", of: User.self)

// 插入
let user = User()
user.name = "张三"
user.email = "zhangsan@example.com"
try database.insert(user, intoTable: "users")

// 查询
let users: [User] = try database.getObjects(
    fromTable: "users",
    where: User.Properties.name == "张三",
    orderBy: [User.Properties.createdAt.order(.descending)],
    limit: 10
)

// 更新
try database.update(table: "users",
                    on: User.Properties.email,
                    with: "new@example.com",
                    where: User.Properties.id == 1)

// 删除
try database.delete(fromTable: "users",
                    where: User.Properties.id == 1)

5.3 数据库加密

WCDB内置了SQLCipher加密支持,加密对上层接口完全透明:

let database = Database(at: dbPath)
database.setCipher(key: "encryption_key".data(using: .utf8)!)

加密机制:SQLCipher对每个数据库页面使用AES-256-CBC加密。每次读写页面时透明地加密/解密。加密后的数据库文件无法用标准SQLite工具打开。

5.4 损坏修复

SQLite数据库在极端情况下(如磁盘空间不足、突然断电、操作系统bug)可能损坏。WCDB提供了Repair功能,可以从损坏的数据库文件中尽可能恢复数据:

// 备份修复所需的元数据
database.backup()

// 数据库损坏时执行修复
database.retrieve { fraction in
    // fraction: 修复进度 (0.0 ~ 1.0)
}

修复原理:在数据库正常时备份B-Tree的页面结构信息(sqlite_master表和各B-Tree根页号),当数据库损坏时,利用备份的结构信息直接遍历未损坏的叶子页面来恢复数据行。

5.5 线程安全模型

WCDB的线程安全模型基于连接池自动事务

graph TD
    T1["线程1
读操作"] --> POOL["连接池
Connection Pool"] T2["线程2
读操作"] --> POOL T3["线程3
写操作"] --> POOL POOL --> C1["Connection 1
(读)"] POOL --> C2["Connection 2
(读)"] POOL --> C3["Connection 3
(写)"] C1 --> DB["数据库文件
(WAL模式)"] C2 --> DB C3 --> DB
  • 读操作可以并发执行(多个连接从连接池获取)
  • 写操作串行执行(SQLite WAL模式的限制)
  • 连接池自动管理连接的分配与回收,上层无需关心线程安全问题

六、Core Data——Apple官方的对象图管理框架

Core Data不仅仅是一个数据库框架——它是一个完整的对象图管理和持久化框架。底层默认使用SQLite作为持久化存储,但也支持内存存储和二进制文件存储。

6.1 核心架构

graph TD
    subgraph "Core Data Stack"
        MODEL["NSManagedObjectModel
数据模型(.xcdatamodeld)"] PSC["NSPersistentStoreCoordinator
持久化存储协调器"] STORE["NSPersistentStore
SQLite / InMemory / Binary"] CONTEXT["NSManagedObjectContext
托管对象上下文"] OBJECT["NSManagedObject
托管对象"] end MODEL --> PSC PSC --> STORE CONTEXT --> PSC OBJECT --> CONTEXT STORE --> DB["SQLite数据库文件"]
  • NSManagedObjectModel:对应.xcdatamodeld文件,定义实体(Entity)、属性(Attribute)和关系(Relationship)
  • NSPersistentStoreCoordinator:协调器,连接数据模型和持久化存储,处理数据的序列化/反序列化
  • NSManagedObjectContext:内存中的"工作区",所有对象的增删改都在Context中进行,调用save()才写入磁盘
  • NSManagedObject:托管对象,Core Data管理的数据对象

现代iOS开发中通常使用NSPersistentContainer来简化Stack的初始化:

class CoreDataStack {
    static let shared = CoreDataStack()
    
    lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "DataModel")
        container.loadPersistentStores { description, error in
            if let error = error {
                fatalError("Core Data加载失败: \(error)")
            }
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
        return container
    }()
    
    var viewContext: NSManagedObjectContext {
        persistentContainer.viewContext
    }
    
    func newBackgroundContext() -> NSManagedObjectContext {
        persistentContainer.newBackgroundContext()
    }
}

6.2 多线程模型

Core Data的线程安全规则只有一条:NSManagedObjectContext和NSManagedObject不能跨线程使用

Core Data提供两种并发模式:

  • mainQueueConcurrencyType:只能在主线程使用(viewContext
  • privateQueueConcurrencyType:拥有自己的私有队列(后台Context)
// 后台写入
let context = CoreDataStack.shared.newBackgroundContext()
context.perform {
    let user = NSEntityDescription.insertNewObject(forEntityName: "User", into: context) as! UserEntity
    user.name = "张三"
    user.email = "zhangsan@example.com"
    
    do {
        try context.save()
    } catch {
        context.rollback()
    }
}

// 主线程读取(UI绑定)
let fetchRequest: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name CONTAINS[cd] %@", "张")
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
fetchRequest.fetchBatchSize = 20

let results = try CoreDataStack.shared.viewContext.fetch(fetchRequest)

跨Context同步:当后台Context保存数据后,viewContext需要知道这些变化。设置automaticallyMergesChangesFromParent = true后,viewContext会自动合并来自同一NSPersistentStoreCoordinator的其他Context的变化。

6.3 性能优化

Faulting(惰性加载)

Core Data的对象默认是"fault"状态——一个轻量级占位符,只包含objectID。当访问对象的属性时,Core Data才从磁盘加载实际数据。这避免了一次性加载大量数据到内存。

let users = try context.fetch(fetchRequest)
// users中的对象此时可能是fault状态
// 访问 users[0].name 时才触发实际的数据加载

Batch操作

对于大量数据的操作,逐条处理效率极低。Core Data提供了批量操作直接在SQLite层面执行,绕过Context:

// 批量删除
let deleteRequest = NSBatchDeleteRequest(fetchRequest: UserEntity.fetchRequest())
deleteRequest.resultType = .resultTypeObjectIDs
let result = try context.execute(deleteRequest) as? NSBatchDeleteResult
let objectIDs = result?.result as? [NSManagedObjectID] ?? []
NSManagedObjectContext.mergeChanges(
    fromRemoteContextSave: [NSDeletedObjectsKey: objectIDs],
    into: [context]
)

// 批量更新
let updateRequest = NSBatchUpdateRequest(entityName: "User")
updateRequest.predicate = NSPredicate(format: "isActive == NO")
updateRequest.propertiesToUpdate = ["isArchived": true]
try context.execute(updateRequest)

NSFetchedResultsController

UITableView/UICollectionView配合使用的利器,自动监听数据变化并驱动UI更新:

let controller = NSFetchedResultsController(
    fetchRequest: fetchRequest,
    managedObjectContext: viewContext,
    sectionNameKeyPath: nil,
    cacheName: "users"
)
controller.delegate = self
try controller.performFetch()

6.4 数据迁移

当数据模型发生变化(如添加新属性、修改关系)时,需要进行数据迁移。

轻量级迁移(Lightweight Migration):对于简单的变更(添加属性、添加实体、添加可选关系等),Core Data可以自动推断迁移映射:

let description = NSPersistentStoreDescription()
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
container.persistentStoreDescriptions = [description]

重量级迁移:对于复杂的变更(属性重命名、数据转换、关系重构),需要创建NSMappingModel手动定义迁移规则。

6.5 Core Data的优势与局限

优势局限
Apple官方维护,与系统深度集成学习曲线陡峭
强大的对象图管理(关系、级联删除)NSPredicate查询语法不如SQL直观
内置变更追踪和撤销/重做调试困难(SQL日志需要启动参数)
NSFetchedResultsController驱动UI多线程模型容易出错
iCloud同步(NSPersistentCloudKitContainer)性能调优需要深入理解内部机制

七、SwiftData——Swift原生的数据持久化

SwiftData是Apple在WWDC 2023推出的全新框架,底层仍然基于Core Data,但使用Swift宏和语言特性提供了更简洁的API。

7.1 模型定义

SwiftData使用@Model宏替代.xcdatamodeld文件,模型定义直接用Swift代码:

import SwiftData

@Model
class User {
    var name: String
    @Attribute(.unique) var email: String
    var createdAt: Date
    
    @Relationship(deleteRule: .cascade)
    var posts: [Post] = []
    
    init(name: String, email: String) {
        self.name = name
        self.email = email
        self.createdAt = Date()
    }
}

@Model
class Post {
    var title: String
    var content: String
    var user: User?
    
    init(title: String, content: String) {
        self.title = title
        self.content = content
    }
}

@Model宏在编译期自动为类生成:PersistentModel协议遵循、属性的getter/setter拦截(实现惰性加载和变更追踪)、Codable支持等。

7.2 容器与上下文

// App入口配置
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [User.self, Post.self])
    }
}

// SwiftUI View中使用
struct UserListView: View {
    @Environment(\.modelContext) private var context
    @Query(sort: \User.createdAt, order: .reverse) private var users: [User]
    
    var body: some View {
        List(users) { user in
            VStack(alignment: .leading) {
                Text(user.name)
                Text(user.email)
                    .foregroundStyle(.secondary)
            }
        }
    }
    
    func addUser() {
        let user = User(name: "新用户", email: "new@example.com")
        context.insert(user)
        // SwiftData自动保存,无需手动调用save()
    }
    
    func deleteUsers(at offsets: IndexSet) {
        for index in offsets {
            context.delete(users[index])
        }
    }
}

7.3 #Predicate宏

SwiftData使用#Predicate宏替代NSPredicate,提供编译期类型检查:

// 类型安全的查询——拼写错误或类型不匹配会在编译期报错
let predicate = #Predicate<User> { user in
    user.name.contains("张") && user.createdAt > someDate
}

let descriptor = FetchDescriptor<User>(
    predicate: predicate,
    sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = 20

let users = try context.fetch(descriptor)

对比NSPredicate

// NSPredicate——字符串拼接,运行时才能发现错误
let predicate = NSPredicate(format: "name CONTAINS[cd] %@ AND createdAt > %@", "张", someDate as NSDate)

7.4 SwiftData vs Core Data

对比项SwiftDataCore Data
模型定义@Model宏,纯Swift代码.xcdatamodeld可视化编辑器
查询语法#Predicate宏,编译期检查NSPredicate字符串
SwiftUI集成@Query属性包装器需要手动桥接
并发Actor隔离的ModelContext手动管理Context线程归属
最低版本iOS 17+iOS 3+
成熟度较新,仍在演进十多年的生产验证
OC支持不支持支持

SwiftData适合iOS 17+的新项目,尤其是与SwiftUI深度集成的场景。但对于需要支持低版本系统或需要更精细控制的场景,Core Data仍然是更稳妥的选择。

八、Realm——高性能对象数据库

Realm是一个为移动端设计的对象数据库,不基于SQLite,而是自研的存储引擎。

8.1 存储引擎

Realm的核心特性是零拷贝架构

graph LR
    subgraph "传统ORM (SQLite + ORM)"
        SQL_DB["SQLite文件"] --> |反序列化| ROW["SQL行数据"]
        ROW --> |对象映射| OBJ1["Swift对象
(堆内存)"] end subgraph "Realm 零拷贝" REALM_DB["Realm文件
(mmap映射)"] --> |直接访问| OBJ2["Realm对象
(指针指向mmap区域)"] end

传统ORM从SQLite读取数据需要经过:磁盘读取 → SQL行解析 → 对象创建和属性赋值。Realm通过mmap将整个数据文件映射到内存,对象的属性访问直接读取mmap区域的数据,无需拷贝和转换。

这使得Realm的读取性能通常优于基于SQLite的方案,特别是在读取大量对象时。

8.2 基本使用

import RealmSwift

class Dog: Object {
    @Persisted(primaryKey: true) var id: ObjectId
    @Persisted var name: String = ""
    @Persisted var age: Int = 0
    @Persisted var owner: User?
}

class User: Object {
    @Persisted(primaryKey: true) var id: ObjectId
    @Persisted var name: String = ""
    @Persisted var dogs: List<Dog>
}

// 写入
let realm = try! Realm()
try! realm.write {
    let user = User()
    user.name = "张三"
    
    let dog = Dog()
    dog.name = "旺财"
    dog.age = 3
    dog.owner = user
    user.dogs.append(dog)
    
    realm.add(user)
}

// 查询——惰性求值,不会立即加载所有结果
let puppies = realm.objects(Dog.self)
    .where { $0.age < 2 }
    .sorted(byKeyPath: "name")

// 实时通知
let token = puppies.observe { changes in
    switch changes {
    case .initial(let results):
        // 初始数据加载完成
        print("当前有\(results.count)只小狗")
    case .update(let results, let deletions, let insertions, let modifications):
        // 数据发生变化
        print("新增: \(insertions), 删除: \(deletions), 修改: \(modifications)")
    case .error(let error):
        print("观察错误: \(error)")
    }
}

8.3 线程模型

Realm的线程安全规则:Realm实例和Realm对象不能跨线程传递。每个线程需要创建自己的Realm实例。

// 在后台线程操作
DispatchQueue.global().async {
    autoreleasepool {
        let realm = try! Realm()
        
        try! realm.write {
            let user = User()
            user.name = "后台创建的用户"
            realm.add(user)
        }
    }
}

Realm提供了ThreadSafeReference来安全地跨线程传递对象引用:

let user = realm.objects(User.self).first!
let reference = ThreadSafeReference(to: user)

DispatchQueue.global().async {
    let realm = try! Realm()
    guard let user = realm.resolve(reference) else { return }
    try! realm.write {
        user.name = "更新后的名字"
    }
}

Realm 10+还引入了冻结对象(Frozen Objects),冻结后的对象是不可变的快照,可以安全地跨线程传递:

let frozenUser = user.freeze()
DispatchQueue.global().async {
    print(frozenUser.name)  // 安全访问
}

8.4 Realm的优势与局限

优势局限
零拷贝读取,性能优异不支持SQL,复杂查询能力有限
实时数据通知,响应式编程对象必须继承Object,侵入性强
内置加密(AES-256)不支持跨进程访问
Realm Sync云同步数据库文件不能用标准工具查看
模型迁移比Core Data简单不支持聚合查询(GROUP BY等)

九、数据库索引原理

索引是数据库性能优化中最重要的工具。理解索引的工作原理,有助于写出高性能的数据库代码。

9.1 索引的本质

数据库索引类似于书籍的目录——在不翻阅整本书(全表扫描)的情况下快速定位到目标内容。

SQLite的索引是一棵独立的B-Tree,Key是被索引列的值,Value是对应数据行的rowid。

假设有一张用户表,存了8条数据:

表数据(按rowid顺序存储):
rowid=1: {name:"张三", age:25, city:"北京"}
rowid=2: {name:"李四", age:30, city:"上海"}
rowid=3: {name:"王五", age:22, city:"北京"}
rowid=4: {name:"赵六", age:28, city:"广州"}
rowid=5: {name:"钱七", age:35, city:"深圳"}
rowid=6: {name:"孙八", age:22, city:"杭州"}
rowid=7: {name:"周九", age:31, city:"成都"}
rowid=8: {name:"吴十", age:28, city:"武汉"}

现在执行查询:SELECT * FROM users WHERE age = 28

无索引——全表扫描:数据库不知道 age=28 的行在哪里,只能从第1行开始逐行检查,直到扫完全部8行。即使第4行就找到了一条,数据库也不能停下来,因为后面可能还有——事实上 rowid=8 也符合条件。数据量越大,查询越慢,时间复杂度 O(N)。

graph LR
    subgraph "全表扫描过程"
        R1["rowid=1
age=25 ✗"] --> R2["rowid=2
age=30 ✗"] R2 --> R3["rowid=3
age=22 ✗"] R3 --> R4["rowid=4
age=28 ✓"] R4 --> R5["rowid=5
age=35 ✗"] R5 --> R6["rowid=6
age=22 ✗"] R6 --> R7["rowid=7
age=31 ✗"] R7 --> R8["rowid=8
age=28 ✓"] end

每一行都要检查,8条数据就要比较8次。如果表里有100万行,就要比较100万次。

有索引——B-Tree查找:对 age 列建立索引后,SQLite会构建一棵B-Tree,所有 age 值按顺序组织成树形结构:

graph TD
    ROOT["[28]"] --> LEFT["[22, 25]"]
    ROOT --> RIGHT["[30, 31, 35]"]
    LEFT --> L1["22 → rowid: 3,6"]
    LEFT --> L2["25 → rowid: 1"]
    LEFT --> L3["28 → rowid: 4,8"]
    RIGHT --> R1["30 → rowid: 2"]
    RIGHT --> R2["31 → rowid: 7"]
    RIGHT --> R3["35 → rowid: 5"]

查找 age=28 的过程:

  1. 从根节点开始:28 等于根节点的值28,找到了对应的叶子节点
  2. 叶子节点告诉我们:age=28 的数据在 rowid=4 和 rowid=8
  3. 根据 rowid 直接定位到表中的第4行和第8行取出完整数据(这步叫"回表")

整个过程只需要 2~3 次比较就能定位到结果,无论表中有多少行数据。这就是 O(log N) 和 O(N) 的差距——100万行数据,全表扫描要比较100万次,B-Tree查找只需要约20次(log₂1000000 ≈ 20)。

为什么B-Tree适合做索引? 因为B-Tree的每个节点可以存储多个Key并拥有多个子节点,树的高度很低。SQLite默认的页大小是4096字节,一个索引节点通常能容纳数百个Key,所以即使是百万级数据,B-Tree的高度也只有34层——也就是说只需要34次磁盘I/O就能定位到任何一条记录。

9.2 复合索引与最左前缀

复合索引是对多个列建立的索引,列的顺序非常重要:

CREATE INDEX idx_city_age ON users(city, age);

这个索引按(city, age)的组合排序:

("北京", 25) → rowid=1
("北京", 25) → rowid=3
("广州", 28) → rowid=4
("上海", 30) → rowid=2

最左前缀原则——查询条件必须从索引最左列开始才能命中:

查询条件是否命中索引原因
WHERE city = '北京' AND age = 25完全命中两列都匹配
WHERE city = '北京'部分命中最左列匹配
WHERE age = 25不命中跳过了最左列
WHERE city = '北京' AND age > 20部分命中city精确匹配,age范围扫描

9.3 索引的代价

索引不是免费的:

  • 空间代价:每个索引是一棵独立的B-Tree,占用额外存储空间
  • 写入代价:INSERT/UPDATE/DELETE操作需要同时更新所有相关索引
  • 维护代价:索引碎片化后需要REINDEX重建

9.4 索引实践建议

适合建索引的场景

场景示例原因
频繁出现在WHERE条件中的列WHERE user_id = ?将全表扫描降为B-Tree查找
ORDER BY / GROUP BY 的列ORDER BY created_at DESC索引本身有序,避免额外排序
JOIN连接条件的列ON a.user_id = b.user_id加速表连接时的匹配查找
高选择性的列(不同值多)用户ID、手机号、订单号每次查找能过滤掉大部分数据

不适合建索引的场景

场景示例原因
低选择性的列is_deleted(只有0和1)索引几乎无法缩小扫描范围,B-Tree查找后仍需遍历约一半的数据
数据量很小的表配置表(几十行)全表扫描本身就很快,索引的B-Tree查找反而多了一次间接寻址
写多读少的表日志表(大量INSERT,很少SELECT)每次写入都要同时维护索引的B-Tree,拖慢写入性能
频繁大批量更新的列每次请求都更新的last_active_time每次UPDATE都触发索引重排,性能损耗大于查询收益
对列使用函数或运算的查询WHERE LOWER(name) = 'test'索引存储的是原始值,函数运算后无法命中索引(需要建函数索引)

十、数据库方案选型指南

不同的业务场景应选择不同的数据库方案:

graph TD
    START["数据持久化需求"] --> Q1{"数据类型?"}
    
    Q1 --> |"简单KV
(配置、标记)"| Q2{"安全要求?"} Q2 --> |普通| KV_NORMAL{"写入频率?"} KV_NORMAL --> |低频| UD["UserDefaults"] KV_NORMAL --> |高频| MMKV_R["MMKV"] Q2 --> |"需要加密
(密码、Token)"| KC["Keychain"] Q1 --> |"结构化数据
(需要查询)"| Q3{"项目语言?"} Q3 --> |"纯Swift
iOS 17+"| SD["SwiftData"] Q3 --> |"Swift/OC混合
需要低版本兼容"| Q4{"团队偏好?"} Q4 --> |"Apple生态
对象图管理"| CD["Core Data"] Q4 --> |"SQL直觉
高性能需求"| WCDB_R["WCDB"] Q4 --> |"简单易用
零拷贝读取"| REALM["Realm"] Q1 --> |"简单对象
(归档)"| CODABLE["Codable + 文件存储"]
场景推荐方案理由
用户偏好设置UserDefaults系统原生,简单可靠
高频读写的配置/缓存MMKVmmap写入,性能远超UserDefaults
密码、TokenKeychain系统级硬件加密
IM消息存储WCDB高写入性能,加密支持,损坏修复
复杂对象关系(社交关系图等)Core Data对象图管理,关系维护
SwiftUI新项目SwiftDataSwift原生,与SwiftUI深度集成
跨平台项目Realm支持iOS/Android/Web
只读的本地数据集SQLite直接使用简单直接,无需ORM开销

十一、常见面试问题

Q: SQLite的WAL模式是什么?为什么推荐使用?

WAL(Write-Ahead Logging)是SQLite的一种日志模式。与默认的回滚日志模式不同,WAL将修改先写入WAL文件而不是直接修改数据库文件。核心优势是读写可以并发执行——读取者看到的是最近一次提交前的一致性快照,写入者的操作不会阻塞读取。iOS上推荐使用WAL模式以获得更好的并发性能。

Q: 数据库索引为什么能加速查询?什么时候不该建索引?

没有索引时,数据库只能从第一行开始逐行扫描直到最后一行(全表扫描,O(N))。索引本质是一棵独立的B-Tree,将被索引列的值按顺序组织成树形结构。由于B-Tree每个节点可容纳数百个Key,树高度极低(百万级数据通常只有34层),查找任意值只需34次节点比较即可定位,时间复杂度O(log N)。例如100万行数据,全表扫描最坏需要100万次比较,B-Tree查找只需约20次。不该建索引的情况:低选择性的列(如is_deleted只有0和1,索引无法有效缩小范围)、数据量很小的表(全表扫描本身就很快)、写多读少的表(每次写入都要维护索引B-Tree)、频繁更新的列(每次UPDATE都触发索引重排)、以及查询条件中对列使用了函数的情况(如WHERE LOWER(name) = 'test',索引存的是原始值无法命中)。

Q: Realm的零拷贝是怎么实现的?

Realm使用mmap将数据文件映射到进程的虚拟地址空间。Realm对象的属性访问实际上是通过计算属性直接读取mmap区域中对应偏移量的数据,不需要将数据从磁盘复制到中间缓冲区再复制到对象属性中。这消除了传统ORM的"读取行 → 解析 → 创建对象 → 赋值属性"的多次内存拷贝过程。

Q: MMKV相比UserDefaults的性能优势在哪里?

UserDefaults每次写入都需要将整个plist字典重新序列化并写入磁盘,时间复杂度与总数据量成正比。MMKV基于mmap内存映射和protobuf序列化,使用增量追加写入策略——新数据直接追加到文件末尾,单次写入时间与写入数据量成正比,与总数据量无关。同时mmap写入由操作系统内核负责异步刷盘,即使进程崩溃也不会丢失数据。