3

WCDB 源码解析

 3 years ago
source link: https://xiangwangfeng.com/2018/01/08/WCDB-%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

WCDB 源码解析

08 Jan 2018

最近开了个新项目,项目的主程童鞋引入了 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 对应关系如下

wctbinding.jpg

下面仅以 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源码优化实践》 并查找对应源码进行对照。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK