事务管理

LMDB中所有的读写都是通过事务来执行,LMDB事务具备以下特点:

  • 支持事务嵌套(??)
  • 读写事务可以并发执行,但写写事务需要被串行化

因此,在lmdb实现中,为了保证写事务的串行化执行,事务执行之前首先要获取全局的写锁。

TxId管理

LMDB中的每个事务都分配唯一的事务编号,且事务编号还需要被持久化。我想这是出于事务的ACID特性考虑。

LMDB中事务实现

在LMDB中,事务分为两种:读事务与写事务。其中读事务可并发,而写事务只能串行执行。

事务表

LMDB初始化时会创建一张读事务表,该表记录了当前所有的读事务以及读事务的执行者(持有该事务的进程与线程id)。之所以需要这样一张读事务表,原因我们在后面详述。而该读事务表不仅在内存中维护,同时该信息也会被持久化到磁盘上,具体来说,位于数据库文件相同目录下的lock.mdb文件,事务表的文件存储格式如下图所示:

LMDB调研

该事务表在LMDB被打开时初始化,具体来说是在函数mdb_env_setup_locks中创建该表,其调用流程如下:

mdb_env_open(MDB_env *env, const char *path, unsigned int flags, mdb_mode_t mode) {
    ......
    /* For RDONLY, get lockfile after we know datafile exists */
    if (!(flags & (MDB_RDONLY|MDB_NOLOCK))) {      
        rc = mdb_env_setup_locks(env, lpath, mode, &excl);
        if (rc)
             goto leave;
    }
    ......
}

写事务

LMDB开始写事务,最终是进入分支mdb_txn_renew0,如下:

static int
mdb_txn_renew0(MDB_txn *txn)
{
    MDB_env *env = txn->mt_env;
    MDB_txninfo *ti = env->me_txns;
    MDB_meta *meta;
    unsigned int i, nr, flags = txn->mt_flags;
    uint16_t x;
    int rc, new_notls = 0;
    // 读事务初始化
    if ((flags &= MDB_TXN_RDONLY) != 0) {
        ......
    } else {
        // 一般总是成立
        // 于是,首先加锁,保证写事务串行化执行
        if (ti) {
            if (LOCK_MUTEX(rc, env, env->me_wmutex))
                return rc;
            // 从事务表中获取现在应当从哪个id开始
            // 并选择应该使用哪个meta page(后面详述该过程)
            txn->mt_txnid = ti->mti_txnid;
            meta = env->me_metas[txn->mt_txnid & 1];
        } else {
            meta = mdb_env_pick_meta(env);
            txn->mt_txnid = meta->mm_txnid;
        }
        txn->mt_txnid++;
        txn->mt_child = NULL;
        txn->mt_loose_pgs = NULL;
        txn->mt_loose_count = 0;
        txn->mt_dirty_room = MDB_IDL_UM_MAX;
        txn->mt_u.dirty_list = env->me_dirty_list;
        txn->mt_u.dirty_list[0].mid = 0;
        txn->mt_free_pgs = env->me_free_pgs;
        txn->mt_free_pgs[0] = 0;
        txn->mt_spill_pgs = NULL;
        env->me_txn = txn;
        memcpy(txn->mt_dbiseqs, env->me_dbiseqs, env->me_maxdbs * sizeof(unsigned int));
    }
    ......
}

这里最重要的可能就是为当前的写事务txn分配了txnid。而且发现这里的txnid是来自于我们前面描述的事务表的头部记录的当前事务id+1,我们后面再来评判这种做法为什么是对的。

读事务

我们上面描述了在lmdb中存在一个读事务表,其中记录了所有已发起的读事务。因此,发起读事务的第一步便是在该表中寻找空闲表项用以记录本次读事务,如果无空闲表项,则返回失败。

static int
mdb_txn_renew0(MDB_txn *txn)
{
    MDB_env *env = txn->mt_env;
    MDB_txninfo *ti = env->me_txns;
    MDB_meta *meta;
    unsigned int i, nr, flags = txn->mt_flags;
    uint16_t x;
    int rc, new_notls = 0;

    // 读事务处理逻辑
    if ((flags &= MDB_TXN_RDONLY) != 0) {
        if (!ti) {
            ......
        } else {
            if (r) {
                ......
            } else {
                // 寻找空闲项
                MDB_PID_T pid = env->me_pid;
                MDB_THR_T tid = pthread_self();
                mdb_mutexref_t rmutex = env->me_rmutex;

                if (!env->me_live_reader) {
                    ......
                }
                // 查找空闲表项时需要lock
                if (LOCK_MUTEX(rc, env, rmutex))
                    return rc;
                nr = ti->mti_numreaders;
                // 寻找空闲表项逻辑,忽略
                ......
                do 
                r->mr_txnid = ti->mti_txnid;
            while(r->mr_txnid != ti->mti_txnid);
            txn->mt_txnid = r->mr_txnid;
            txn->mt_u.reader = r;
            meta = env->me_metas[txn->mt_txnid & 1];
            }
        }
    }
    ......
}

这里需要注意的是:读事务txn使用的id源自ti->mti_txnid,而该值只有在写事务完成后才会进行递增变化,这就意味着,如果没有写事务,那么所有的读事务均会使用相同的id。对于读事务来说,好像并没有什么问题。

page管理

lmdb中最核心的数据结构应该是非page莫属了,因为LMDB所有的数据与元数据均存储在page内。

meta page

meta page占据了LMDB的起始两个page,在LMDB被初次创建的时候,meta page会一并被初始化。

meta page初始化代码如下:

static void ESECT
mdb_env_init_meta0(MDB_env *env, MDB_meta *meta)
{
    meta->mm_magic = MDB_MAGIC;
    meta->mm_version = MDB_DATA_VERSION;
    meta->mm_mapsize = env->me_mapsize;
    meta->mm_psize = env->me_psize;
    meta->mm_last_pg = NUM_METAS-1;
    meta->mm_flags = env->me_flags & 0xffff;
    meta->mm_flags |= MDB_INTEGERKEY; /* this is mm_dbs[FREE_DBI].md_flags */
    meta->mm_dbs[FREE_DBI].md_root = P_INVALID;
    meta->mm_dbs[MAIN_DBI].md_root = P_INVALID;
}

LMDB初次创建的时候,读取meta page的内容会返回空,因此需要初始化meta内容,并将这些meta内容写入到page 0和page 1这两个起始page中。初始时两个meta page内容一致。

static int ESECT
mdb_env_init_meta(MDB_env *env, MDB_meta *meta)
{
    MDB_page *p, *q;
    int rc;
    unsigned int     psize;

    DPUTS("writing new meta page");

    psize = env->me_psize;

    // 分配两个page,用来存放meta内容
    p = calloc(NUM_METAS, psize);
    if (!p)
        return ENOMEM;
    // 设置meta page 0的header
    p->mp_pgno = 0;
    p->mp_flags = P_META;
    // 向meta page 0写入内容
    *(MDB_meta *)METADATA(p) = *meta;

    q = (MDB_page *)((char *)p + psize);
    q->mp_pgno = 1;
    q->mp_flags = P_META;
    *(MDB_meta *)METADATA(q) = *meta;

    // 写入LMDB的数据文件
    DO_PWRITE(rc, env->me_fd, p, psize * NUM_METAS, len, 0);
    if (!rc)
        rc = ErrCode();
    else if ((unsigned) len == psize * NUM_METAS)
        rc = MDB_SUCCESS;
    else
        rc = ENOSPC;
    free(p);
    return rc;
}

LMDB调研

因为系统存在两个meta page,每次写入或者读取的时候均需要选定一个meta page,计算规则很简单:

meta = env->me_metas[txn->mt_txnid & 1]

用本次操作的事务id取最低位,如果是0,使用meta page 0,否则使用meta page 1。我们后面会解释为什么这种算法是正确的。

双meta_page轮流使用,假如当前事务Txnid使用了meta_page 0,那么下一次事务(Txnid + 1)就会使用meta_page 1。之所以这样做,是为了保证在异常时可以恢复出该事务执行前的状态(因为meta page中记录了B+ tree的root page,可以进一步索引到所有的数据)。这同时也解释了为什么所有的读事务都不会增加Txnid,因为紧接着写事务的读事务如果也增加txnid的话,根据

meta = env->me_metas[txn->mt_txnid & 1]

这个计算规则,它就没法找到正确的B+ tree,从而读取数据。

关于LMDB的异常处理,我们在后面会仔细阐述。

data page

page结构

LMDB的data page结构如下:

LMDB调研

其中data page由page header以及page内容组成:

  • page header:描述page元信息,如page id,flag等
  • page 内容:page内容是MDB_node的数组,MDB_node描述的是存储该page内的KV信息,包括key/value等。

关于LMDB如何插入MDB_node以及在插入过程中可能会遇到的page分裂等情况,我们就不在这里详细描述了,这部分并不难。

COW技术

但是有一个点需要特别注意的是:由于采用了COW技术,LMDB在定位需要更新的page之后,需要将该原始page内容拷贝一份至新申请的空闲page。其实不仅仅是该KV应该占据的Leaf Page,而是沿着root page直到Leaf Page的查找路径上的所有page都会被拷贝一份。因为接下来的更新操作可能会将这一条路径上的所有page完全更新一遍,如下图所示:

LMDB调研

上图中假如某个KV记录的搜索路径如红色图形代表,那么这些红色图形所代表的page内容都需要被拷贝一份。

这一切的准备工作(COW分配)都在下面的过程中完成

int
mdb_cursor_put(MDB_cursor *mc, MDB_val *key, MDB_val *data, unsigned int flags)
{
    ......
    if (rc == MDB_NO_ROOT) {
        ......
    } else {
        // 这里会将KV查找路径上的page拷贝一份
        // 实现Copy-On-Write
        rc2 = mdb_cursor_touch(mc);
        if (rc2)
            return rc2;
    }
    ......
}
static int
mdb_cursor_touch(MDB_cursor *mc)
{
    int rc = MDB_SUCCESS;

    if (mc->mc_dbi >= CORE_DBS && !(*mc->mc_dbflag & DB_DIRTY)) {
        ......
    }
    mc->mc_top = 0;
    // mc->mc->top[]记录了KV查找路径中的所有page
    if (mc->mc_snum) {
        do {
            // 在mdb_page_touch中会对路径中的每个原始page进行一次拷贝
            rc = mdb_page_touch(mc);
        } while (!rc && ++(mc->mc_top) < mc->mc_snum);
        mc->mc_top = mc->mc_snum-1;
    }
    return rc;
}
static int
mdb_page_touch(MDB_cursor *mc)
{
    MDB_page *mp = mc->mc_pg[mc->mc_top], *np;
    MDB_txn *txn = mc->mc_txn;
    MDB_cursor *m2, *m3;
    pgno_t  pgno;
    int rc;

    if (!F_ISSET(mp->mp_flags, P_DIRTY)) {
        if (txn->mt_flags & MDB_TXN_SPILLS) {
            ......
        }
        // 这里mdb_page_alloc创建新的page
        if ((rc = mdb_midl_need(&txn->mt_free_pgs, 1)) ||
            (rc = mdb_page_alloc(mc, 1, &np)))
            goto fail;
        // np记录新分配page信息
        pgno = np->mp_pgno;
        ......
        if (mc->mc_top) {
            ......
        } else {
            mc->mc_db->md_root = pgno;
        }
    } else if (txn->mt_parent && !IS_SUBP(mp)) {
        ......
    } else {
        return 0;
    }
    // 将原始page(mp)的内容拷贝到新申请的page(np)
    mdb_page_copy(np, mp, txn->mt_env->me_psize);
    np->mp_pgno = pgno;
    np->mp_flags |= P_DIRTY;

// 完成拷贝,接下来就是将新分配的page放在
// 搜索路径,取代原始路径上的page,接下来
// 处于搜索路径上的page便可以安心被修改了
done:
    mc->mc_pg[mc->mc_top] = np;
    m2 = txn->mt_cursors[mc->mc_dbi];
    if (mc->mc_flags & C_SUB) {
        ......
    } else {
        ......
    }
    MDB_PAGE_UNREF(mc->mc_txn, mp);
    return 0;

fail:
    txn->mt_flags |= MDB_TXN_ERROR;
    return rc;
}

从上面的整个流程中我们可以清晰地看到:在写入KV记录之前,LMDB会将该KV的搜索路径上的每一个page复制出来一份,替换原来的搜索路径的page,此时,接下来的写入可以安心地修改这些page了,而不用担心会影响其他事物的操作。

因为在写入前已经将本次修改时可能会涉及的page全部分配了新的副本,接下来的修改只针对这些新副本,最终在提交事务时也会将这些修改后的新副本写入至磁盘文件中。

空闲page管理

由于LMDB采用了COW技术,那随之而来的问题是:如何回收那些不再被引用的(过时)page?

考虑下面这种情况:

LMDB调研

一次新的写入将page 2、4、10、15变成了过时的page(即这些page不再会被索引到),那LMDB如何识别这些过时page并回收他们呢?

先说结论:

LMDB会在写事务提交时(txn_commit)将该事务中释放的所有page(id)插入到一棵Free page的B+树中。因为写事务过程中使用了COW技术,那些被Copy的page就被当作该事务所释放的page。该事务理所应当地认为这些page可以被释放了,但是这些在该事务看来可以被释放以便重复再利用的page可能此刻正被其他读事务所reference,实际上是依然不能被重复利用的,这是后话,我们下面会仔细描述。

写事务就这样释放了写过程中释放的那些page。对于读事务来说,因为它在事务整个生命周期中不会分配新的页面,也不会释放旧页面,因此,不存在页面释放的问题。

写事务在释放旧页面(其实是释放这些页面的id,以后申请也只是申请这些id而已)时,会将这些待释放页面按照id进行排序,然后将排序后的id数组作为KV记录的value存入Free B+树上,而key则是释放这些page id的写事务id。这个过程见函数mdb_freelist_save(),被函数mdb_txn_commit()调用。

页面分配

在数据写入过程中,难免需要分配page。上一节大概描述了如何管理free page,通过这个其实也就大概了解了怎么分配page。

LMDB中page分配原则大概是:

  1. 从以前事务释放的free page中选择;
  2. 如果找不到,那么就分配一个全新的page,这个page的page_id会是之前在用的最大的page_id + 1。

其实这个原理随便找个二傻子都能想明白。

这里需要解决的关键问题:

  1. 程序发起一次写事务WX,且WX中拷贝了page M & N,得到M’和N’,而M和N被释放(LMDB的实现上应该是将M&N添加到freeDB的B+ tree上);
  2. 应用程序发起一次读事务RY(RY >= WX),且RY会引用page M;
  3. WX结束,又发起了一次写事务WZ(WZ > RY),此时WZ再申请空闲页面,那么问题来了,过程1被free的page M可以被再利用吗?

分析:

  1. 如果RY不再引用page M,那么WZ可以被分配给page M
  2. 如果RY此时依然占用page M,那么M便不能分配给WZ,试想下,RY依然在读取page M的内容,而WZ却已经将M的内容修改了,欲哭无泪,是不是?
  3. 既然如此,便需要设计一种机制来保证:依然被RX引用的page M不会再次分配出去用来写

照例,先说结论:LMDB使用了一种基于txnid的简单机制来保证。为了弄明白这个问题,先来介绍一些简单背景:

  1. 每个写事务txnid是在上一次写事务txnid基础上加1,而写事务在完成后commit时才会将自己的txnid提交,使其对其他事务可见;
  2. 读事务则复用上一次的写事务id,注意不是正在进行的写事务的id哦,因为该写事务还没提交,其txnid还对其他事务不可见呢,这同时也意味着当前时刻所有的读事务可能拥有相同的txnid;
  3. 以上1和2保证了一点:当前正在进行的写事务id wtxnid >= 所有当前读事务rtxnid。

好了,说了这么多技术背景介绍,我们来看看LMDB如何解决这个问题,其实他的解决方案极其简单粗暴:

如果存在读事务RTxn,其RTxnId <= 最近一次释放page id的写事务写事务WTxnId,那么在free B+树上缓存的上次写事务释放的page id还不能被复用,转而扩张新的page id。

这个方案足够简单,也足够粗暴。翻译成人话就是:

如果在当前写事务之前就启动的读事务尚未结束,那么该写事务就无法被分配以前被释放的page id。

原因很简单:

那些读事务有可能还引用着被以前写事务所释放的page。当然,只是有可能,所以说这是一种简单粗暴的方案。

举个栗子:
假如时刻T0,产生了一个新事务WTx,其id为3,WTx要申请可利用的page id,而在之前的事务WTxB(假如为2)中释放了page X,而此时系统中还有一个读事务RTxA尚未结束,其Id为1,这时候,无论RTxA是否引用了page X,这个page X都不会分配给WTx。

但是,如果RTxA已经结束,则此时新产生的读事务其Id 为3,> 最近一次释放page id的写事务2,于是page X可以被愉快地分配给事务WTx使用了,因为所有可能引用page X的读事务都已经结束。

这个机制说起来确实简单,但是我在阅读源码的时候着实费了一点功夫,究其原因,LMDB的代码可读性差难辞其咎,所有功能集中在同一个文件(> 10k line),某些关键函数接近1k line。

异常

对于存储系统来说,最大的异常来自于外部因素如系统断电而导致的数据不一致性问题,此时系统可能会存在垃圾数据。

读事务不会产生任何脏数据的可能,因为读事务不会修改任何的数据,便不会导致系统处于一种不一致状态。

对于写事务,由于采用了COW技术,在写入过程中修改的page也都是原始page的拷贝。直到数据写入完成,事务提交时,这些写入尚不会体现在LMDB中,即新数据虽然写入到新页面了,但是还无法索引到这些新页面。

提交事务的最后一个步骤是将修改后的新的page组织成的B+ tree的根page的信息记录在meta page,同时记录的还有当前进行的事务号txnid。
我们前面说过,LMDB中存在两个meta page:meta page0和meta page 1。对于一个事务,如果其事务Txnid & 1 = 0,则该事务完成时其LMDB的根节点等信息记录在meta page0;如果其事务Txnid & 1 = 0,则该事务完成时其LMDB的根节点等信息记录在meta page1;这样做的好处是:即使一个事务Txn在写入过程中奔溃了,那么能够轻松恢复至该事务发生前的状态。

好,我们先来总结下一个写事务的执行流程:

  1. COW分配新页面,并将本次事务需要修改的页面内容拷贝至新页面;
  2. 修改新页面;
  3. 事务提交1:将新页面内容sync至底层持久化存储;
  4. 事务提交2:修改与txnid对应的meta page,记录新的B+ tree的根节点以及txnid等信息。

我们接下来分析下各种时刻下的异常:

  1. 异常发生在2~3之间,此时旧页面数据被修改但修改数据尚未提交,由于树根节点未发生改变,因此,系统恢复后依然是从旧的B+ tree上去读数据,放佛修改未发生;
  2. 异常发生在3~4之间,同上;
  3. 异常发生在4之后,此时由于新的B+ tree已经生成,本次写入对接下来的事务可见。

通过上面的分析我们发现由于使用了COW以及一些其他辅助技术,在任何情况下均可以保证系统数据一致性。

其他

本文未涉及到的LMDB部分特性包括:

  • 嵌套事务

数据结构

MDB_db

typedef struct MDB_db {
    uint32_t    md_pad;
    uint16_t    md_flags;
    uint16_t    md_depth;
    pgno_t      md_branch_pages;    
    pgno_t      md_leaf_pages;
    pgno_t      md_overflow_pages;  
    mdb_size_t  md_entries; 
    pgno_t      md_root;        
} MDB_db;

MDB_db代表了一个数据库实例,其最核心的成员是md_root,记录了当前数据库page组成的B+ tree的root page的page number,根据该root page便可以查找所有数据。

另外,在LMDB中每时每刻都存在两个MDB_db:第一是数据page组成的B+ tree,称为MAIN_DBI,第二是空闲page组成的B+ tree,称为FREE_DBI。这两个数据库实例的信息被记录在meta page中,下面会说道。

MDB_meta

typedef struct MDB_meta {
    uint32_t    mm_magic;
    uint32_t    mm_version;
#ifdef MDB_VL32
    union {
        MDB_ID  mmun_ull;
        void *mmun_address;
    } mm_un;
#define mm_address mm_un.mmun_address
#else
    void        *mm_address;        
#endif
    pgno_t      mm_mapsize;         
    MDB_db      mm_dbs[CORE_DBS];   
#define mm_psize    mm_dbs[FREE_DBI].md_pad
#define mm_flags    mm_dbs[FREE_DBI].md_flags
    pgno_t      mm_last_pg;
    volatile txnid_t    mm_txnid;
} MDB_meta;

MDB_meta记录了整个LMDB的关键元数据信息,其中mm_dbs[CORE_DBS]则记录了系统两棵B+ tree的元信息,非常关键,这在我们前面已经说过。

其他

其他一些数据结构如 MDB_page、MDB_txn等理解起来没什么难度,所以忽略。

参考