用C/C++构建自己的Redis——第七章、堆数据结构&TTL

用C/C++构建自己的Redis——第七章、堆数据结构&TTL


文章目录

  • 用C/C++构建自己的Redis——第七章、堆数据结构&TTL
  • 前言
  • 一、堆
  • 二、堆的定义
  • 三、堆操作
  • 四、新的定时器
    • 4.1 维护TTL计时器
    • 4.2 发现最近的计时器
    • 4.3 激活计时器
  • 五、新命令
  • 总结


前言

本章我们学习了如何使用C/C++构建自己的Redis服务器,特别是如何实现和管理带TTL(生存时间)的堆数据结构。文章首先回顾了堆数据结构的基本概念,然后详细描述了堆的定义、操作以及如何将堆集成到服务器中以维护TTL计时器。此外,还讨论了如何添加和删除TTL计时器,以及如何找到最近的计时器和触发计时器。最后,介绍了如何添加新的命令来更新和查询TTL。
原书连接:https://build-your-own.org/redis/


一、堆

Redis的主要功能之一,即作为缓存服务器,以及如何通过设置TTL(Time To Live,生存时间)来管理缓存的大小。TTL是一种计时器机制,用于确定数据在缓存中可以存在多长时间。在Redis中,这种计时器可以通过链表来实现,但这种方式在处理大量数据时可能会遇到性能瓶颈。

为了解决这个问题,文中提到了使用堆数据结构来优化这一过程。堆是一种特殊的二叉树,它被存储在一个数组中,并且满足特定的性质:父节点的值总是不大于其子节点的值(在最小堆中)。这种结构允许快速地访问和更新数据,因为它可以保持元素的有序状态。

堆的工作原理如下:

  1. 堆中的元素以数组的形式存储,其中父子节点之间的关系是隐式的,不需要显式的指针来表示。
  2. 堆中的唯一约束是每个父节点的值必须小于或等于其子节点的值。
  3. 当元素的值更新时,如果新值大于旧值,可能需要将其与子节点交换,以维持堆的性质。这个过程会一直进行,直到元素到达叶子节点。
  4. 如果新值小于旧值,也需要进行调整,这次是与父节点交换,直到到达根节点。
  5. 新元素被添加到堆中时,会被放置在数组的末尾,然后通过上浮操作(heap_up)来维护堆的性质。
  6. 当从堆中删除元素时,通常的做法是用数组的最后一个元素来替换被删除的元素,然后通过下沉操作(heap_down)来维护堆的性质。

二、堆的定义

代码:

struct HeapItem {
    uint64_t val = 0;
    size_t *ref = NULL;
};

// the structure for the key
struct Entry {
    struct HNode node;
    std::string key;
    std::string val;
    uint32_t type = 0;
    ZSet *zset = NULL;
    // for TTLs
    size_t heap_idx = -1;
};

在这个上下文中,"侵入式数据结构"指的是数据结构的实现需要被管理的对象(在这个例子中是Entry)内部包含特定的字段(如heap_idx),这些字段用于维护数据结构的组织。这种设计允许数据结构直接操作对象,而不需要额外的映射或查找步骤,从而提高效率。

在这种设计中,每个Entry对象都有一个heap_idx字段,这个字段存储了该Entry在堆中的位置索引。同时,Entry对象内部还有一个ref指针,这个指针指向该Entry对象自己的heap_idx字段。这样的设计使得堆可以快速地通过ref指针找到对应的Entry,并且可以高效地更新堆中元素的位置,因为每个Entry都知道自己在堆中的位置。

这种数据结构的设计通常用于需要快速插入、删除和查找操作的场景,如实现优先队列或定时器等。通过维护一个有序的堆结构,可以确保总是能够快速访问到最小(或最大)的时间戳,而侵入式的设计则减少了额外的内存开销和查找时间。

父子关系图下:

static size_t heap_parent(size_t i) {
    return (i + 1) / 2 - 1;
}

static size_t heap_left(size_t i) {
    return i * 2 + 1;
}

static size_t heap_right(size_t i) {
    return i * 2 + 2;
}

三、堆操作

  1. 交换操作:如果发现某个节点的值小于其父节点的值,这意味着堆的性质被破坏了。为了修复这一点,我们需要将这个子节点与其父节点交换位置。

  2. 更新堆索引:在交换节点时,我们还需要更新堆索引。堆索引通常用于跟踪节点在数组中的位置。由于交换操作改变了节点的位置,因此必须更新这些索引,以确保后续操作能够正确地找到和操作这些节点。

  3. 引用指针:这里的“引用指针”可能是指在编程实现中,我们可能会使用一个指针或引用来指向当前正在处理的节点。在交换节点的过程中,我们通过这个指针来更新节点的索引,确保数据结构的一致性。

static void heap_up(HeapItem *a, size_t pos) {
    HeapItem t = a[pos];
    while (pos > 0 && a[heap_parent(pos)].val > t.val) {
        // swap with the parent
        a[pos] = a[heap_parent(pos)];
        *a[pos].ref = pos;
        pos = heap_parent(pos);
    }
    a[pos] = t;
    *a[pos].ref = pos;
}
static void heap_down(HeapItem *a, size_t pos, size_t len) {
    HeapItem t = a[pos];
    while (true) {
        // find the smallest one among the parent and their kids
        size_t l = heap_left(pos);
        size_t r = heap_right(pos);
        size_t min_pos = -1;
        size_t min_val = t.val;
        if (l < len && a[l].val < min_val) {
            min_pos = l;
            min_val = a[l].val;
        }
        if (r < len && a[r].val < min_val) {
            min_pos = r;
        }
        if (min_pos == (size_t)-1) {
            break;
        }
        // swap with the kid
        a[pos] = a[min_pos];
        *a[pos].ref = pos;
        pos = min_pos;
    }
    a[pos] = t;
    *a[pos].ref = pos;
}

堆的更新

void heap_update(HeapItem *a, size_t pos, size_t len) {
    if (pos > 0 && a[heap_parent(pos)].val > a[pos].val) {
        heap_up(a, pos);
    } else {
        heap_down(a, pos, len);
    }
}

四、新的定时器

添加堆到服务器

// global variables
static struct {
    HMap db;
    // a map of all client connections, keyed by fd
    std::vector<Conn *> fd2conn;
    // timers for idle connections
    DList idle_list;
    // timers for TTLs
    std::vector<HeapItem> heap;
} g_data;

4.1 维护TTL计时器

更新、添加和从堆中移除计时器。只需在更新数组中的元素后调用 heap_update 函数。

// set or remove the TTL
static void entry_set_ttl(Entry *ent, int64_t ttl_ms) {
    if (ttl_ms < 0 && ent->heap_idx != (size_t)-1) {
        // erase an item from the heap
        // by replacing it with the last item in the array.
        size_t pos = ent->heap_idx;
        g_data.heap[pos] = g_data.heap.back();
        g_data.heap.pop_back();
        if (pos < g_data.heap.size()) {
            heap_update(g_data.heap.data(), pos, g_data.heap.size());
        }
        ent->heap_idx = -1;
    } else if (ttl_ms >= 0) {
        size_t pos = ent->heap_idx;
        if (pos == (size_t)-1) {
            // add an new item to the heap
            HeapItem item;
            item.ref = &ent->heap_idx;
            g_data.heap.push_back(item);
            pos = g_data.heap.size() - 1;
        }
        g_data.heap[pos].val = get_monotonic_usec() + (uint64_t)ttl_ms * 1000;
        heap_update(g_data.heap.data(), pos, g_data.heap.size());
    }
}

在删除一个条目(Entry)时,需要移除与之关联的可能存在的生存时间(Time-To-Live,简称TTL)计时器

static void entry_del(Entry *ent) {
    switch (ent->type) {
    case T_ZSET:
        zset_dispose(ent->zset);
        delete ent->zset;
        break;
    }
    entry_set_ttl(ent, -1);
    delete ent;
}

4.2 发现最近的计时器

next_timer_ms 函数被修改,以便同时使用空闲定时器(idle timers)和生存时间定时器(TTL timers)。在计算机科学和编程中,定时器是一种计时机制,用于在特定时间间隔后执行某些操作。空闲定时器通常在系统没有其他任务执行时触发,而TTL定时器(Time To Live timers)则用于设置一个时间限制,超过这个时间限制后,如果没有重置,定时器就会触发。这句话表明,next_timer_ms 函数现在可以处理这两种类型的定时器,使得它能够更灵活地适应不同的计时需求。

static uint32_t next_timer_ms() {
    uint64_t now_us = get_monotonic_usec();
    uint64_t next_us = (uint64_t)-1;

    // idle timers
    if (!dlist_empty(&g_data.idle_list)) {
        Conn *next = container_of(g_data.idle_list.next, Conn, idle_list);
        next_us = next->idle_start + k_idle_timeout_ms * 1000;
    }

    // ttl timers
    if (!g_data.heap.empty() && g_data.heap[0].val < next_us) {
        next_us = g_data.heap[0].val;
    }

    if (next_us == (uint64_t)-1) {
        return 10000;   // no timer, the value doesn't matter
    }

    if (next_us <= now_us) {
        // missed?
        return 0;
    }
    return (uint32_t)((next_us - now_us) / 1000);
}

4.3 激活计时器

将TTL计时器添加到process_timers函数中

static void process_timers() {
    // the extra 1000us is for the ms resolution of poll()
    uint64_t now_us = get_monotonic_usec() + 1000;

    // idle timers
    while (!dlist_empty(&g_data.idle_list)) {
        // code omitted...
    }

    // TTL timers
    const size_t k_max_works = 2000;
    size_t nworks = 0;
    while (!g_data.heap.empty() && g_data.heap[0].val < now_us) {
        Entry *ent = container_of(g_data.heap[0].ref, Entry, heap_idx);
        HNode *node = hm_pop(&g_data.db, &ent->node, &hnode_same);
        assert(node == &ent->node);
        entry_del(ent);
        if (nworks++ >= k_max_works) {
            // don't stall the server if too many keys are expiring at once
            break;
        }
    }
}

具体来说,它涉及到一个“堆”(heap)的数据结构,这是一种通常用于存储一组元素并允许快速访问最小元素的数据结构。这里提到的“堆”可能是用来管理一些需要定时过期的键(keys),比如缓存系统中的条目。

五、新命令

更新和查询TTLs(Time To Live,生存时间)的命令

static void do_expire(std::vector<std::string> &cmd, std::string &out) {
    int64_t ttl_ms = 0;
    if (!str2int(cmd[2], ttl_ms)) {
        return out_err(out, ERR_ARG, "expect int64");
    }

    Entry key;
    key.key.swap(cmd[1]);
    key.node.hcode = str_hash((uint8_t *)key.key.data(), key.key.size());

    HNode *node = hm_lookup(&g_data.db, &key.node, &entry_eq);
    if (node) {
        Entry *ent = container_of(node, Entry, node);
        entry_set_ttl(ent, ttl_ms);
    }
    return out_int(out, node ? 1: 0);
}
static void do_ttl(std::vector<std::string> &cmd, std::string &out) {
    Entry key;
    key.key.swap(cmd[1]);
    key.node.hcode = str_hash((uint8_t *)key.key.data(), key.key.size());

    HNode *node = hm_lookup(&g_data.db, &key.node, &entry_eq);
    if (!node) {
        return out_int(out, -2);
    }

    Entry *ent = container_of(node, Entry, node);
    if (ent->heap_idx == (size_t)-1) {
        return out_int(out, -1);
    }

    uint64_t expire_at = g_data.heap[ent->heap_idx].val;
    uint64_t now_us = get_monotonic_usec();
    return out_int(out, expire_at > now_us ? (expire_at - now_us) / 1000 : 0);
}

总结

代码汇总如下:
server.cpp
avl.cpp
avl.h
common.h
hashtable.cpp
hashtable.h
heap.cpp
heap.h
list.h
test_heap.cpp
zset.cpp
zset.h

你可能感兴趣的:(Redis,c语言,c++,redis,服务器)