Redis源码、面试指南(4)单机数据库、持久化、通知与订阅(上)

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介: Redis源码、面试指南(4)单机数据库、持久化、通知与订阅

四、数据库实现

在Redis中,服务器中所有的数据库都保存在redis.h/redisServer结构中的db数组中:

struct redisServer {
    // ……
    // 保存服务器中所有的数据库
    redisDb *db;
    //……
    // 决定服务器初始化时创建的数据库数量
    // 默认16
    int dbnum;
}

默认配置下的服务器启动之后的状态如下:

对于redis的客户端而言,其结构中的db属性指向了当前正在操作的目标数据库,假设一个客户端的目标数据库为1,实例如下:


注:可以通过select指令进行数据库的切换(其实就是更改db的指向)。

数据库键空间

概念

Redis服务器中的每个数据库都由redis.h/redisDb结构表示,如下:

typedef struct redisDb {
    // 数据库键空间,保存着数据库中的所有键值对
    dict *dict;                 /* The keyspace for this DB */
    // 键的过期时间,字典的键为键,字典的值为过期事件 UNIX 时间戳
    dict *expires;              /* Timeout of keys with a timeout set */
    // 正处于阻塞状态的键
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP) */
    // 可以解除阻塞的键
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    // 正在被 WATCH 命令监视的键
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    struct evictionPoolEntry *eviction_pool;    /* Eviction pool of keys */
    // 数据库号码
    int id;                     /* Database ID */
    // 数据库的键的平均 TTL ,统计信息
    long long avg_ttl;          /* Average TTL, just for stats */
} redisDb;

其中dict字典保存了该数据库的所有键值对,我们就将其称为键空间。

键空间和用户所见的数据库是直接对应的:

·键空间的键也就是数据库的键,每个键都是一个字符串对象。

·键空间的值也就是数据库的值,每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象在内的任意一种 Redis 对象。

例如下图就是一个拥有一个字符串对象、哈希对象、列表对象的数据库键空间的实例:


对一个数据库键进行添加、删除、更新、取值,其实都是先在键空间中取出键,而后再操作其对应的值对象。

另外,在读写键的时候,需要进行一些维护操作

  • 在读取一个键之后(读操作和写操作都要对键进行读取),服务器会根据键是否存在,以此来更新服务器的键空间命中(hit)次数或键空间不命中(miss)次数, 这两个值可以在 INFO stats 命令的 keyspace_hits 属性和 keyspace_misses 属性中查看。
  • 在读取一个键之后,服务器会更新键的 LRU (最后一次使用)时间这个值可以用于计算键的闲置时间,使用命令OBJECT idletime 命令可以查看键key的闲置时间。
  • 如果服务器在读取一个键时,发现该键已经过期,那么服务器会先删除这个过期键,然后才执行余下的其他操作
  • 如果有客户端使用WATCH命令监视了某个键,那么服务器在对被监视的键进行修改之后会将这个键标记为脏(dirty), 从而让事务程序注意到这个键已经被修改过。
  • 服务器每次修改一个键之后,都会对脏(dirty)键计数器的值增一, 这个计数器会触发服务器的持久化以及复制操作执行
  • 如果服务器开启了数据库通知功能,那么在对键进行修改之后, 服务器将按配置发送相应的数据库通知

生存/过期时间

数据库redisDb结构中的expire字典保存过期时间,故称其为过期字典。

一个数据库的实例展示如下:

注:过期字典中存储的对象跟键空间的对象是共享的

expire(秒)/pexpire(毫秒)命令可以使得客户端以秒或者毫秒精度为某个键设置生存时间(TTL)经过指定时间之后,服务器就会删除生存时间为0的键。

expireat/pexpireat命令可以设置过期时间,即到达那个时刻就删除。

四种命令如下:

注:其实这四种命令都可以看成**pexpireat,**它们之间的关系如下:

它们的底层代码如下**:**

// 命令的第二个参数可能是绝对值,也可能是相对值。
//当执行 *AT 命令时, basetime 为 0 ,在其他情况下,它保存的就是当前的绝对时间。
//unit 用于指定 argv[2] (传入过期时间)的格式,
//它可以是 UNIT_SECONDS 或 UNIT_MILLISECONDS ,
// basetime 参数则总是毫秒格式的。
void expireGenericCommand(redisClient *c, long long basetime, int unit) {
    robj *key = c->argv[1], *param = c->argv[2];
    long long when; /* unix time in milliseconds when the key will expire. */

    // 取出 when 参数
    if (getLongLongFromObjectOrReply(c, param, &when, NULL) != REDIS_OK)
        return;

    // 如果传入的过期时间是以秒为单位的,那么将它转换为毫秒
    if (unit == UNIT_SECONDS) when *= 1000;
    when += basetime;

    /* No key, return zero. */
    // 取出键
    if (lookupKeyRead(c->db,key) == NULL) {
        addReply(c,shared.czero);
        return;
    }

    /* 在载入数据时,或者服务器为附属节点时,
     * 即使 EXPIRE 的 TTL 为负数,或者 EXPIREAT 提供的时间戳已经过期,
     * 服务器也不会主动删除这个键,而是等待主节点发来显式的 DEL 命令。
     * 程序会继续将(一个可能已经过期的 TTL)设置为键的过期时间,
     * 并且等待主节点发来 DEL 命令。
     */
    if (when <= mstime() && !server.loading && !server.masterhost) {

        // when 提供的时间已经过期,服务器为主节点,并且没在载入数据

        robj *aux;

        redisAssertWithInfo(c,key,dbDelete(c->db,key));
        server.dirty++;

        /* Replicate/AOF this as an explicit DEL. */
        // 传播 DEL 命令
        aux = createStringObject("DEL",3);

        rewriteClientCommandVector(c,2,aux,key);
        decrRefCount(aux);

        signalModifiedKey(c->db,key);
        notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"del",key,c->db->id);

        addReply(c, shared.cone);

        return;
    } else {

        // 设置键的过期时间
        // 如果服务器为附属节点,或者服务器正在载入,
        // 那么这个 when 有可能已经过期的
        setExpire(c->db,key,when);

        addReply(c,shared.cone);

        signalModifiedKey(c->db,key);
        notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"expire",key,c->db->id);
        server.dirty++;
        return;
    }
}

另外,可以使用persist命令解除过期时间;TTL/PTTL返回键的剩余生存时间。

过期删除策略

有三种常见的删除策略:

·定时删除:在设置键过期时间时,创建一个定时器,使得当定时器时间来临时,立即删除键;

该方法对内存友好,因为可以保证尽快删除过期的键并释放内存;缺点就是对CPU不友好,因为频繁的删除键会占用CPU时间,可能会影响吞吐量和响应时间。另外,定时器事件的时间复杂度为O(N)。

·惰性删除:过期键并不立即删除,而是每次取键时看它过期没有,过期就删除,没有就返回该键;

这种方式对CPU友好,因为仅在需要该键时才会检查,但缺点就是对内存不友好,过多的过期键会占用大量的内存,甚至可以看成内存泄漏——因为有的键所占用内存可能一直不会释放。

·定时删除:两者的一种折中方式,隔一段时间对数据库检查,删除其中过期的键,检查策略是重点:检查间隔跟检查数量。

Redis采用的是惰性删除和定期删除策略

惰性删除

源码在db.c/expireIfNeeded,Redis在读写所有键的时候会检查其输入键是否过期:

int expireIfNeeded(redisDb *db, robj *key) {
    // 取出键的过期时间
    mstime_t when = getExpire(db,key);
    mstime_t now;
    // 没有过期时间
    if (when < 0) return 0; /* No expire for this key */
    // 如果服务器正在进行载入,那么不进行任何过期检查
    if (server.loading) return 0;

    now = server.lua_caller ? server.lua_time_start : mstime();

    // 当服务器运行在 replication 模式时
    // 附属节点并不主动删除 key
    // 它只返回一个逻辑上正确的返回值
    // 真正的删除操作要等待主节点发来删除命令时才执行
    // 从而保证数据的同步
    if (server.masterhost != NULL) return now > when;

    // 运行到这里,表示键带有过期时间,并且服务器为主节点

    /* Return when this key has not expired */
    // 如果未过期,返回 0
    if (now <= when) return 0;
    /* Delete the key */
    server.stat_expiredkeys++;
    // 向 AOF 文件和附属节点传播过期信息
    propagateExpire(db,key);
    // 发送事件通知
    notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
        "expired",key,db->id);
    // 将过期键从数据库中删除
    return dbDelete(db,key);
}

定期删除

源码在redis.c/activeExpireCycle,该函数会在规定时间内分多次遍历服务器的各个数据库,从expire字典中随机检查一部分键的过期时间,并删除其中的过期键

 void activeExpireCycle(int type) {
    // 静态变量,用来累积函数连续执行时的数据
    static unsigned int current_db = 0; /* Last DB tested. */
    static int timelimit_exit = 0;      /* Time limit hit in previous call? */
    static long long last_fast_cycle = 0; /* When last fast cycle ran. */
    unsigned int j, iteration = 0;
    // 默认每次处理的数据库数量
    unsigned int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL;
    // 函数开始的时间
    long long start = ustime(), timelimit;
    // 快速模式
    if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
        // 如果上次函数没有触发 timelimit_exit ,那么不执行处理
        if (!timelimit_exit) return;
        // 如果距离上次执行未够一定时间,那么不执行处理
        if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return;
        // 运行到这里,说明执行快速处理,记录当前时间
        last_fast_cycle = start;
    }

    /* 一般情况下,函数只处理 REDIS_DBCRON_DBS_PER_CALL 个数据库
     *    除非:
     *    当前数据库的数量小于 REDIS_DBCRON_DBS_PER_CALL
     *     如果上次处理遇到了时间上限,那么这次需要对所有数据库进行扫描,
     *     这可以避免过多的过期键占用空间
     */
    if (dbs_per_call > server.dbnum || timelimit_exit)
        dbs_per_call = server.dbnum;
    // 函数处理的微秒时间上限
    // ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 默认为 25 ,也即是 25 % 的 CPU 时间
    timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERCrver.hz/100;
    timelimit_exit = 0;
    if (timelimit <= 0) timelimit = 1;
    // 如果是运行在快速模式之下
    // 那么最多只能运行 FAST_DURATION 微秒 
    // 默认值为 1000 (微秒)
    if (type == ACTIVE_EXPIRE_CYCLE_FAST)
        timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /* in microseconds. */
    // 遍历数据库
    for (j = 0; j < dbs_per_call; j++) {
        int expired;
        // 指向要处理的数据库
        redisDb *db = server.db+(current_db % server.dbnum);

        // 为 DB 计数器加一,如果进入 do 循环之后因为超时而跳出
        // 那么下次会直接从下个 DB 开始处理
        current_db++;
        do {
            unsigned long num, slots;
            long long now, ttl_sum;
            int ttl_samples;
            // 获取数据库中带过期时间的键的数量
            // 如果该数量为 0 ,直接跳过这个数据库
            if ((num = dictSize(db->expires)) == 0) {
                db->avg_ttl = 0;
                break;
            }
            // 获取数据库中键值对的数量
            slots = dictSlots(db->expires);
            // 当前时间
            now = mstime();

            // 这个数据库的使用率低于 1% ,扫描起来太费力了(大部分都会 MISS)
            // 跳过,等待字典收缩程序运行
            if (num && slots > DICT_HT_INITIAL_SIZE &&
                (num*100ots < 1)) break;

            /* The main collection cycle. Sample random keys among keys
             * with an expire set, checking for expired ones. 
             *
             * 样本计数器
             */
            // 已处理过期键计数器
            expired = 0;
            // 键的总 TTL 计数器
            ttl_sum = 0;
            // 总共处理的键计数器
            ttl_samples = 0;

            // 每次最多只能检查 LOOKUPS_PER_LOOP 个键
            if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
                num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;

            // 开始遍历数据库
            while (num--) {
                dictEntry *de;
                long long ttl;

                // 从 expires 中随机取出一个带过期时间的键
                if ((de = dictGetRandomKey(db->expires)) == NULL) break;
                // 计算 TTL
                ttl = dictGetSignedIntegerVal(de)-now;
                // 如果键已经过期,那么删除它,并将 expired 计数器增一
                if (activeExpireCycleTryExpire(db,de,now)) expired++;
                if (ttl < 0) ttl = 0;
                // 累积键的 TTL
                ttl_sum += ttl;
                // 累积处理键的个数
                ttl_samples++;
            }

            /* Update the average TTL stats for this database. */
            // 为这个数据库更新平均 TTL 统计数据
            if (ttl_samples) {
                // 计算当前平均值
                long long avg_ttl = ttl_suml_samples;

                // 如果这是第一次设置数据库平均 TTL ,那么进行初始化
                if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
                /* Smooth the value averaging with the previous one. */
                // 取数据库的上次平均 TTL 和今次平均 TTL 的平均值
                db->avg_ttl = (db->avg_ttl+avg_ttl)/2;
            }

            // 我们不能用太长时间处理过期键,
            // 所以这个函数执行一定时间之后就要返回
            // 更新遍历次数
            iteration++;
            // 每遍历 16 次执行一次
            if ((iteration & 0xf) == 0 && /* check once every 16 iterations. */
                (ustime()-start) > timelimit)
            {
                // 如果遍历次数正好是 16 的倍数
                // 并且遍历的时间超过了 timelimit
                // 那么断开 timelimit_exit
                timelimit_exit = 1;
            }

            // 已经超时了,返回
            if (timelimit_exit) return;

            // 如果已删除的过期键占当前总数据库带过期时间的键数量的 25 %
            // 那么不再遍历
        } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
    }
} 

AOF、RDB、复制对过期键的处理

·若是生成RDB文件,即SAVE、BGSAVE命令,在保存过程中会对数据库中的键进行检查,过期的键将不保存;

若是载入RDB文件,当以主服务器运行时,只会载入未过期的键;若以从服务器运行,所有键都会载入

·若是写入AOF,某个键过期还未删除不会造成影响,只有当惰性删除/定期删除策略将其删除时,AOF会显式追加DEL命令,提示已删除;

若是载入AOF,会检查是否过期,过期的就不载入;

·若是复制操作时,从服务器不会在意该键时是过期还是不过期,它的过期删除操作由主服务器控制,也就是说当主服务器删除一个过期键后,它会通知所有从服务器删除;若是主服务器没有发送,那么从服务器依旧像是处理未过期键一样;这样做主要是为了保证主从服务器的数据一致性

通知与订阅

Redis2.8版本中新增了数据库的通知功能:客户端可以订阅指定的频道/模式来获取数据库中键的变化及命令执行情况

下面的指令展示了一个客户端订阅0号数据库中针对message键的命令(键空间通知:某个键执行了什么命令)


有一种是键事件通知(某个命令被什么键执行了),如下:

通知与订阅功能的源码文件为notify.c及pubsub.c,具体而言:

发布通知API:

// notify.c
/* 
 * type 参数表示该通知类型
 * event 参数是一个字符串表示的事件名
 * key 参数是一个 Redis 对象表示的键名
 * dbid 参数为键所在的数据库ID
 */
void notifyKeyspaceEvent(int type, char *event, robj *key, int dbid) ;

每当一个Redis命令需要发送数据库通知时,该命令的实现函数就会调用notifyKeyspaceEvent函数,并向函数传递相关信息。例如执行SADD命令,其实现函数进行通知的部分代码如下:

void saddCommand(redisClient *c){
    ...
    // 当至少有一个元素被成功添加
    if(added){
        ...
        // 传递该命令的相关信息
        notifyKeyspaceEvent(REDIS_NOTIFY_SET,"sadd",c->argv[1].c->db->id);
        }
    ...
}

 Redis源码、面试指南(4)单机数据库、持久化、通知与订阅(中):https://developer.aliyun.com/article/1508243

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
3天前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
14 2
|
6天前
|
缓存 NoSQL 关系型数据库
大厂面试高频:如何解决Redis缓存雪崩、缓存穿透、缓存并发等5大难题
本文详解缓存雪崩、缓存穿透、缓存并发及缓存预热等问题,提供高可用解决方案,帮助你在大厂面试和实际工作中应对这些常见并发场景。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:如何解决Redis缓存雪崩、缓存穿透、缓存并发等5大难题
|
1月前
|
NoSQL Java API
美团面试:Redis锁如何续期?Redis锁超时,任务没完怎么办?
在40岁老架构师尼恩的读者交流群中,近期有小伙伴在面试一线互联网企业时遇到了关于Redis分布式锁过期及自动续期的问题。尼恩对此进行了系统化的梳理,介绍了两种核心解决方案:一是通过增加版本号实现乐观锁,二是利用watch dog自动续期机制。后者通过后台线程定期检查锁的状态并在必要时延长锁的过期时间,确保锁不会因超时而意外释放。尼恩还分享了详细的代码实现和原理分析,帮助读者深入理解并掌握这些技术点,以便在面试中自信应对相关问题。更多技术细节和面试准备资料可在尼恩的技术文章和《尼恩Java面试宝典》中获取。
美团面试:Redis锁如何续期?Redis锁超时,任务没完怎么办?
|
18天前
|
存储 NoSQL Redis
Redis常见面试题:ZSet底层数据结构,SDS、压缩列表ZipList、跳表SkipList
String类型底层数据结构,List类型全面解析,ZSet底层数据结构;简单动态字符串SDS、压缩列表ZipList、哈希表、跳表SkipList、整数数组IntSet
|
1月前
|
缓存 NoSQL 算法
面试题:Redis如何实现分布式锁!
面试题:Redis如何实现分布式锁!
|
3月前
|
存储 Java
【IO面试题 四】、介绍一下Java的序列化与反序列化
Java的序列化与反序列化允许对象通过实现Serializable接口转换成字节序列并存储或传输,之后可以通过ObjectInputStream和ObjectOutputStream的方法将这些字节序列恢复成对象。
|
8天前
|
存储 算法 Java
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
本文详解自旋锁的概念、优缺点、使用场景及Java实现。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
|
9天前
|
存储 缓存 Java
大厂面试必看!Java基本数据类型和包装类的那些坑
本文介绍了Java中的基本数据类型和包装类,包括整数类型、浮点数类型、字符类型和布尔类型。详细讲解了每种类型的特性和应用场景,并探讨了包装类的引入原因、装箱与拆箱机制以及缓存机制。最后总结了面试中常见的相关考点,帮助读者更好地理解和应对面试中的问题。
33 4
|
1月前
|
算法 Java 数据中心
探讨面试常见问题雪花算法、时钟回拨问题,java中优雅的实现方式
【10月更文挑战第2天】在大数据量系统中,分布式ID生成是一个关键问题。为了保证在分布式环境下生成的ID唯一、有序且高效,业界提出了多种解决方案,其中雪花算法(Snowflake Algorithm)是一种广泛应用的分布式ID生成算法。本文将详细介绍雪花算法的原理、实现及其处理时钟回拨问题的方法,并提供Java代码示例。
67 2
|
1月前
|
JSON 安全 前端开发
第二次面试总结 - 宏汉科技 - Java后端开发
本文是作者对宏汉科技Java后端开发岗位的第二次面试总结,面试结果不理想,主要原因是Java基础知识掌握不牢固,文章详细列出了面试中被问到的技术问题及答案,包括字符串相关函数、抽象类与接口的区别、Java创建线程池的方式、回调函数、函数式接口、反射以及Java中的集合等。
28 0