WCDB 源码解析

起因

最近开了个新项目,项目的主程童鞋引入了 WCDB 代替原先自制的 KeyValueStoreFMDB。问为何,答曰:好用,线程安全又高效。又问具体实现细节,答曰:不懂,就是好用。所以作为一个负责任的 前 iOS 开发 决定花点时间扒一扒 WCDB 的实现。

WCDBWiki 介绍了它的三大特性:易用,高效和完整。通过 ORMWINQWCDB 能提供非常简洁的数据访问接口,减少调用者错误使用的可能性。而通过整个框架的设计及局部代码优化则使得整体较为高效。下面主要扒一扒这两个特性如何达到。至于完整性,尤其是损坏修复由于涉及过多 SQLite 文件格式,不在本文讨论范围内,有兴趣的可以参考 《微信 SQLite 数据库修复实践》《Database File Format》

ORM 和 WINQ

iOS 上实现 ORM 并不是什么新鲜事。一般的流程是:给定一个类,继承基类(非必须),通过 runtime 对其属性进行读写,并使用协议或基类方法进行约束。以 Realm 为例,所有模型对象需要继承自 RLMObject 并通过重写基类方法指定主键,忽略属性等。但市面上的 ORM 实现往往存在如下问题 (也是 WCDB 尝试解决的问题)

  • 部分流程仍需要使用硬编码,有出错概率
  • 约束表达力有限,无法覆盖复杂应用场景

同样以 Realm 为例,指定数据模型主键时,需要重写 + (NSString *)primaryKey 方法,此时不可避免使用硬编码字符串。而在约束表达力上,Realm 也存在无法使用联合索引的问题。

回到 WCDB,它使用内建宏实现 ORM 功能:将一个已有对象进行 ORM 绑定时,我们需要指定其遵守 WCTTableCoding 协议并通过各种内建宏完成绑定和约束 —- 凭借宏强大的表达力能够有效避免上述问题。

一个完整的 WCTTableCoding 协议如下


@protocol WCTTableCoding
@required
+ (const WCTBinding *)objectRelationalMappingForWCDB;
+ (const WCTPropertyList &)AllProperties;
+ (const WCTAnyProperty &)AnyProperty;
+ (WCTPropertyNamed)PropertyNamed; //className.PropertyNamed(propertyName)
@optional
@property(nonatomic, assign) long long lastInsertedRowID;
@property(nonatomic, assign) BOOL isAutoIncrement;
@end

其中和 ORM 关联最密切的自然是 objectRelationalMappingForWCDB 这个方法,通过它返回类和数据库表的绑定关系,即 WCTBinding。而每一个 WCTBinding 又包含字段绑定关系(WCTColumnBinding),约束绑定关系(WCTConstraintBindingBase),索引绑定关系(WCTIndexBinding),分别通过对应的宏实现:字段宏,约束宏和索引宏。关于几种宏的定义和使用可以参考这里

一个完整 ORM 对应关系如下

下面仅以 WCTSampleORM 中的例子来解释各个类型宏的实现原理,类定义和实现如下:


@interface WCTSampleORM : NSObject

@property(nonatomic, assign) int identifier;
@property(nonatomic, retain) NSString *desc;
@property(nonatomic, assign) float value;
@property(nonatomic, retain) NSString *timestamp;
@property(nonatomic, assign) WCTSampleORMType type;

@end

@implementation WCTSampleORM

WCDB_IMPLEMENTATION(WCTSampleORM)
WCDB_SYNTHESIZE(WCTSampleORM, identifier)
WCDB_SYNTHESIZE_COLUMN(WCTSampleORM, desc, "description") //use "description" as column name in Database
WCDB_SYNTHESIZE_DEFAULT(WCTSampleORM, value, 1.0f)
WCDB_SYNTHESIZE_DEFAULT(WCTSampleORM, timestamp, WCTDefaultTypeCurrentTimestamp)
WCDB_SYNTHESIZE(WCTSampleORM, type)

WCDB_PRIMARY(WCTSampleORM, identifier)

@end

首先是 WCDB_IMPLEMENTATION(WCTSampleORM) 这个宏,展开后是:


static WCTBinding _s_WCTSampleORM_binding(WCTSampleORM.class);
static WCTPropertyList _s_WCTSampleORM_properties;
+(const WCTBinding *) objectRelationalMappingForWCDB 
{ 
    if (self.class != WCTSampleORM.class) 
    { 
        WCDB::Error::Abort("Inheritance is not supported for ORM"); 
    } 
    return &_s_WCTSampleORM_binding; 
} 
+(const WCTPropertyList &)AllProperties 
{ 
    return _s_WCTSampleORM_properties; 
}
 +(const WCTAnyProperty &)AnyProperty
{
    static const WCTAnyProperty s_anyProperty(WCTSampleORM.class);
    return s_anyProperty; 
}
+(WCTPropertyNamed) PropertyNamed 
{
    return WCTProperty::PropertyNamed; 
}

这里我们只关注 objectRelationalMappingForWCDB 这个方法,会发现这个宏只做了一件事:初始化名为 _s_WCTSampleORM_binding 的静态变量,并通过 objectRelationalMappingForWCDB 方法返回。

(这里有个小贴士,由于 WCDB 的宏定义较为复杂,推荐通过 Xcode[Product -> Perform Action -> Preprocess] 菜单进行代码的预处理)

通过 WCDB_IMPLEMENTATION 宏,我们已经可以从类定义中获取对应的 WCTBinding 信息,唯一的问题是它里面空空如也,需要通过其他宏进行填充,这里主要依靠 字段宏:它提供对象属性和表字段的对应关系。

字段宏

同样以

WCDB_SYNTHESIZE(WCTSampleORM, identifier)

为例,我们将这个宏展开,就得到了如下代码


+(const WCTProperty &)identifier 
{ 
    static const WCTProperty s_property( "identifier", WCTSampleORM.class, _s_WCTSampleORM_binding .addColumnBinding<decltype([WCTSampleORM new].identifier)>("identifier", "identifier"));
     return s_property; 
} 
static const auto _unused0 = [](WCTPropertyList &propertyList) 
{ 
    propertyList.push_back(WCTSampleORM.identifier); 
    return nullptr; 
}(_s_WCTSampleORM_properties);

这个宏做了两件事情:

  • 生成和属性同名的静态方法,返回值为 WCTProperty,同时将字段绑定关系 WCTColumnBinding 添加至 _s_WCTSampleORM_binding
  • 通过匿名函数调用,将上一步返回值加入属性列表 _s_WCTSampleORM_properties

一个完整的字段绑定关系往往包括如下字段

  • 数据模型类名
  • 绑定属性名
  • 数据库字段名字
  • 属性类型

这些都可以通过字段宏自动生成。而 WCDB 在实现字段宏时使用了两个比较 tricky 的写法:

  • addColumnBinding 时使用了 decltype,传入的表达式为 [WCTSampleORM new].identifier。这样做一方面可以使用这个特性在编译期间检查属性拼写,如不慎将 identifier 误拼成 identifer 则会产生编译错误,这就规避了前面提到的硬编码问题,后续的一些宏处理也是同理,不赘述。另一方面也可以通过获取的属性类型动态选择后续使用的 Accessor 类型。

  • _unused0 实际是通过 _unused__COUNTER__ 这个宏拼接而成,通过 __COUNTER__ 可以保证当前文件中每个静态变量的唯一性,同时也可以在该静态方法初始化时调用对应的匿名函数,完成属性绑定关系的添加。

上面的例子是一个简单的字段宏展开结果分析,WCDB 中还内置了几种较为复杂的的字段宏形式,如绑定时指定数据库字段名,或绑定时指定默认值,这些宏实现原理大同小异,无非是宏展开时使用默认值还是上层传入字符串的区别。

这里我们仅以指定默认值这个字段宏实现为例。我们将 WCDB_SYNTHESIZE_DEFAULT(WCTSampleORM, value, 1.0f) 展开后,结果为


static const auto _unused3 = [](WCTBinding *binding) 
{ 
    binding->getColumnBinding(WCTSampleORM.value)->makeDefault<decltype([WCTSampleORM new].value)>( 1.0f);
    return nullptr; 
}(&_s_WCTSampleORM_binding);

为了设定字段默认值,这里又额外添加了一个匿名函数,通过当前绑定关系 WCTBinding 查询属性对应的 字段绑定关系 WCTColumnBinding,并添加约束 (makeDefault)。

通过上面的分析,我们会发现通过字段宏已经可以完成一个 ORM 的雏形,但为了满足更加复杂的场景需求,我们还需要对绑定关系添加额外的约束和索引。

约束宏和索引宏

WCDB 中的约束宏作用与 SQLite 中的约束基本是一一对应的关系,数据库表内每一种单字段约束,如主键,唯一,非空等都对应 WCDB 中的一种约束宏。下面仅以 WCTSampleORM 中的主键宏为例进行说明。我们将 WCDB_PRIMARY(WCTSampleORM, identifier) 展开后得到


 static const auto _unused7 = [](WCTBinding *binding)
{ 
    binding->getColumnBinding(WCTSampleORM.identifier)->makePrimary(WCTOrderedNotSet, false, WCTConflictNotSet);
     return nullptr; }
(&_s_WCTSampleORM_binding);

我们会发现在单字段约束的实现上和字段宏的默认值实现完全一致:通过 _s_WCTSampleORM_binding 查找当前属性对应的字段绑定关系 (WCTColumnBinding),并设置相应的约束。

而多字段约束和索引则需要被记录在额外的约束列表(WCTConstraintBindingBase List/Map) 和索引列表(WCTIndexBinding List/Map)中。上文提到联合索引的实现则是通过同名索引合并的逻辑:每个索引宏都可以指定当前索引的后缀,相同后缀的索引以 WCTIndex 的形式被记录存储于同一项索引绑定关系中(WCTIndexBinding),并在生成索引命令时进行合并(详将 WCTIndexBinding::generateCreateIndexStatement)。

而一旦通过对象获取绑定关系后,后续的流程就非常简单了,无非是 CRUD 而已。

  • C 核心代码参考 WCTInterfacecreateTableAndIndexesOfName:withClass:andError: 方法,通过 WCTColumnBindingWCTConstraintBinding 列表生成建表命令,再通过 WCTIndexBinding 列表添加对应索引。如果已存在表,建表过程则变成更新 column 操作,并忽略约束信息(SQLite 只实现 Alert Table 的有效子集)。

  • R 核心代码参考 WCTSelectextractPropertyToObject:atIndex:withColumnBinding: 方法,通过 WINQ 的链式调用获得最终查询结果,并调用上述方法将数据设置给对象属性。

  • U 核心代码参考 WCTInsertWCTUpdate 的初始化方法,通过 WCTTableCoding 获取 AllProperties 信息并配合当前实例属性进行链式调用,最后输出 SQL 语句执行。

  • D 核心代码参考 WCTDeleteexcute 方法,首先通过 WCTDelete 的链式调用生成最终的 WCTDelete 对象,最后输出 SQL 语句并执行。

上面就个 WCDB 实现 ORM 大致流程。虽然并不一定是最优解,但不可否认的确是非常细致和考虑周到的实现,基本涵盖了日常开发的 99% 的需求。

WINQ 则相对更加简单,其核心思路无非是用具体的类调用代替硬编码的 SQL 语句,同时提供足够丰富的排列组合方式。基本可以将 WINQ 的实现分为两层:

  • C++ 实现层,位于 abstract 目录,主要继承自 Describable ,包括表达式 expr 和执行语句 statement ,用于简化拼装 SQL 语句过程和提供更多的组合可能。
  • Objective-C 接口层,胶水代码,主要起到简化 Objective-C 调用的作用,内部大多持有 C++ 实现的 statement

线程安全和性能

线程安全

在讲线程安全和性能前,必须要了解 SQLite 是怎么实现线程安全和达到高性能,具体可以参考《SQLite 线程安全和并发》。使用 SQLite 时常规的优化方案无非是

  • 缓存 sqlite3_prepare 编译结果
  • 使用 WAL 模式
  • 采用多线程模式,单写多读
  • 合理安排事务

接下来就来扒一扒 WCDB 线程安全的实现细节。这部分代码位于 core 目录中,即所谓的核心层。主要围绕 HandleHandlePoolDatabase 三个类完成。其中 Handle 主要负责持有 sqlite3 指针,即平时说的数据库连接,和 FMDB 对比的话,基本可以认为它是一个 C++ 版本的 FMDatabase。而 HandlePool 的作用基本等同于 FMDatabasePool,起到管理连接的作用。当进行数据库访问时,通过 HandlePool 返回当前可用的连接 (Handle) 进行操作,使用完毕后则回收。详细可以参考 HandlePoolflowOutflowBack 方法


RecyclableHandle HandlePool::flowOut(Error &error)
{
    m_rwlock.lockRead();
    std::shared_ptr<HandleWrap> handleWrap = m_handles.popBack();
    if (handleWrap == nullptr) {
        if (m_aliveHandleCount < s_maxConcurrency) {
            handleWrap = generate(error);
            if (handleWrap) {
                ++m_aliveHandleCount;
                if (m_aliveHandleCount > s_hardwareConcurrency) {
                    WCDB::Error::Warning(
                        ("The concurrency of database:" +
                         std::to_string(tag.load()) + " with " +
                         std::to_string(m_aliveHandleCount) +
                         " exceeds the concurrency of hardware:" +
                         std::to_string(s_hardwareConcurrency))
                            .c_str());
                }
            }
        } else {
            Error::ReportCore(
                tag.load(), path, Error::CoreOperation::FlowOut,
                Error::CoreCode::Exceed,
                "The concurrency of database exceeds the max concurrency",
                &error);
        }
    }
    if (handleWrap) {
        handleWrap->handle->setTag(tag.load());
        if (invoke(handleWrap, error)) {
            return RecyclableHandle(
                handleWrap, [this](std::shared_ptr<HandleWrap> &handleWrap) {
                    flowBack(handleWrap);
                });
        }
    }

    handleWrap = nullptr;
    m_rwlock.unlockRead();
    return RecyclableHandle(nullptr, nullptr);
}

void HandlePool::flowBack(const std::shared_ptr<HandleWrap> &handleWrap)
{
    if (handleWrap) {
        bool inserted = m_handles.pushBack(handleWrap);
        m_rwlock.unlockRead();
        if (!inserted) {
            --m_aliveHandleCount;
        }
    }
}


简单来说,WCDB 的连接池通过读写锁保证线程安全,和 FMDatabasePool 使用 gcd queue 并没有太多差异,一些肉眼可见的区别在于

  • WCDB 并不对外暴露数据库连接对象,以减少外面错误使用的几率。
  • WCDB 在连接池之外还提供基于 ThreadLocal 的缓存机制,保证当前事务操作下永远只使用同一个连接。 (详见 Database::flowOut)
  • 内部自动约束并发数,并对不合理的并发做出提示。比如连接数超过 std::thread::hardware_concurrency() 就会有警告。 (详见 HandlePool::flowOut)
  • 连接回收基于 C++ 变量作用域。这一点上在我看倒没有明显的优劣点,反倒有点炫技的成分,为了实现这一点还需要额外引入 RecylceHandle
  • 支持内存不足时的数据库连接自动回收。 (详见 Database::purgeFreeHandles)

这些细微的差别能够使得 WCDB 在保证线程安全和合理并发的前提下,使用起来更加方便安心。

性能

除了上面说的合理设计框架,合理提供并发外,WCDB 还做了一些额外性能有点。下面仅列出一些我读代码和 wiki 的发现。

  • checkpointing 优化

在使用 WAL 模式时,默认情况下,当 WAL 文件 大小超过 1000 个页大小时,SQLite 就会尝试将 WAL 文件 写回数据库文件,这就是所谓的 checkpointing。(详见 wal) 那么在大量数据批量写入的场景下,可能会不停的产生提交文件到数据库的事务。而 WCDB 的做法则是在触发 checkpointing 时,通过延时队列进行,避免大量写入时不停的触发 WalCheckpoint 调用。

代码如下


[](std::shared_ptr<Handle> &handle, Error &error) -> bool {
             handle->registerCommittedHook(
                 [](Handle *handle, int pages, void *) {
                     static TimedQueue<std::string> s_timedQueue(2);
                     if (pages > 1000) {
                         s_timedQueue.reQueue(handle->path);
                     }
                     static std::thread s_checkpointThread([]() {
                         pthread_setname_np(
                             ("WCDB-" + Database::defaultCheckpointConfigName)
                                 .c_str());
                         while (true) {
                             s_timedQueue.waitUntilExpired(
                                 [](const std::string &path) {
                                     Database database(path);
                                     WCDB::Error innerError;
                                     database.exec(StatementPragma().pragma(
                                                       Pragma::WalCheckpoint),
                                                   innerError);
                                 });
                         }
                     });
                     static std::once_flag s_flag;
                     std::call_once(s_flag,
                                    []() { s_checkpointThread.detach(); });
                 },
                 nullptr);
             return true;
         },

通过 TimedQueue 将同个数据库的 WalCheckpoint 合并延迟到 2 秒后统一进行。

  • SQLITE_BUSY 优化

SQLite 的机制并不允许进行多线程同时进行写操作,当发生多个线程进行写操作时未得到锁的那一方将直接返回 SQLITE_BUSY。从 FMDB 的提交记录我们可以看出,ccgus 对怎么处理 SQLITE_BUSY 也是相当头疼,具体可以参考 FMDB 中关于 SQLITE_BUSYissues。目前 FMDB 的做法是默认重试 2 秒,在此期间调用 sqlite3_sleep 随机休眠几十毫秒,等待另外一个线程释放锁。这种处理方式可以较大程度上缓解 SQLITE_BUSY 的问题,但仍不可避免。这也是 WCDB Benchmark 认为 FMDB 无法支持 Multi-Thread WriteWrite 的原因。

WCDB 的处理方式则相当粗暴:通过修改 sqlcipher 源码,如果当前未进入事务状态而产生 SQLITE_BUSY 则会挂起等待,超时时间为 10 秒。详细代码可以参见 btree.c 中的 sqlite3BtreeBeginTrans 方法。


do {
    //一堆判断
    sqlite3PagerBegin(pBt->pPager,wrflag>1,sqlite3TempInMemory(p->db));
    //一堆判断
}while( (rc&0xFF)==SQLITE_BUSY && pBt->inTransaction==TRANS_NONE &&
          btreeInvokeBusyHandler(pBt) );


  • 编译选项优化

SQLite 有大量预编译宏选项可以配置,具体可以参见 sqliteLimit.hsqliteInt.hWCDB 也对此作了较多配置,具体可以参考 sqlchiper-preprocessed.xcodeproj 中的宏定义。像我在 《SQLite 分表》 提到的 SQLITE_MALLOC_SOFT_LIMIT 就是偷师自微信,通过设置它为 0,可以加快在大量表情况下的初始化过程。从微信分享给出的资料还有相当多的优化项,如 开启 mmap,禁用文件锁(针对 iOS 单进程的场景)等,具体可以参考 《微信iOS SQLite源码优化实践》 并查找对应源码进行对照。