一、背景
Redis 对数据的持久化主要是通过 RDB 和 AOF 两种方式,其中 AOF 可以是混合型持久化,即持久化文件中存在 AOF 和 RDB 两种协议的内容;RDB 本质上是 redis 在某一刻的内存快照,AOF 则根据数据刷盘策略对数据进行追加保存;
因此,RDB 有生成速度快、数据文件小、恢复速度快的优点,但是数据只是某一个时刻的快照,AOF 则有保存数据全,时效性高,根据配置策略保存的优点,但是恢复速度相对较慢的缺点。
本文主要分析关于 RDB 的触发方式以及文件生成的代码流程,以及简单分析 RDB 协议,本文基于 redis 5.0.14 版本代码进行分析。
二、fork() 函数 与 Copy On Write
2.1fork() 函数
c 语言中 fork() 函数为当前进程通过系统调用创建出一个子进程,并且给子进程分配独立的代码空间和数据空间,并将部分数据内容拷贝到新的数据空间中同时也将子进程加到系统进程列表,理论上子进程可以做当前进程接下来要做的所有操作(可以通过初始参数或者传入变量让当前进程和子进程做不同的事)
fork() 函数被调用之后可能返回以下三个值,也就是说 fork() 函数调用成功之后对当前进程返回的值为 fork 出的进程的进程 id,而给它本身进程返回的则是 0,也就是说 fork()函数调用成功之后会有两个返回值:
●小于 0:创建子进程失败
●等于 0:对于新创建的子进程,返回值为 0
●子进程 pid:当前进程返回子进程 id
2.2Copy On Write
Copy On Write 又称为写时复制,fork() 函数底层实现采用了这种技术(COW),当子进程被创建时,子进程的虚拟地址空间所映射的内存和父进程的物理内存空间是相同的;
当物理内存中的内容需要发生改变时(一个内存页)会先复制改变的内存页到子进程的物理内存中,然后再发生改变,通过这种实现方式,避免在 fork 时产生大量的内存占用和消耗过长时间。
以下的代码流程和 redis 中 RDB 的流程类似,if 条件判断中的 fork()函数执行完成时已经存在子进程了,根据进程的子进程 pid 来判断应该执行的逻辑分支,因此在 if 分支中是子进程执行的,而 else 分支则是由当前进程执行的,count 则可以理解成在 RDB 过程中 redis 存储的实际内容,count 的初始值由于在当前进程和子进程中均一样;
因此当子进程先调用函数对值进行改变前需要先将 count 对于对物理内存页拷贝到子进程的实际物理空间中,再对子进程物理空间中存储的值进行改变,而此时当前进程中的 count 值并没有发生改变,当前进程和子进程之间通信通过 pipe 来实现。
三、redis RDB 过程中可能使用到的数据
redisServer 结构体是 redis 中最重要最核心的数据结构之一,其中包含了大量运行时状态、统计等相关信息,以下将涉及到 rdb 的内容摘出来进行分析,方便后续对代码流程的理解。
struct redisServer {
...
// rdb 过程中 COW 消耗d内存大小
size_t stat_rdb_cow_bytes; /* Copy on write bytes during RDB saving. */
// 上次 rdb 结束到本次rdb 开始时键值对改变的个数
long long dirty; /* Changes to DB from the last save */
// 执行 bgsave 的进程的进程id
pid_t rdb_child_pid; /* PID of RDB saving child */
// save 参数保存的rdb 生成策略
struct saveparam *saveparams; /* Save points array for RDB */
// save 参数的个数
int saveparamslen; /* Number of saving points */
// rdb 文件名称
char *rdb_filename; /* Name of RDB file */
// 是否对rdb文件进行压缩 LZF 算法
int rdb_compression; /* Use compression in RDB? */
// 是否进行rdb 文件进行校验
int rdb_checksum; /* Use RDB checksum? */
// 上一次进行save成功的时间
time_t lastsave; /* Unix time of last successful save */
// 上一次尝试执行bgsave的时间
time_t lastbgsave_try; /* Unix time of last attempted bgsave */
// 最近一次执行bgsave花费的时间
time_t rdb_save_time_last; /* Time used by last RDB save run. */
// 当前正在执行的rdb开始时间
time_t rdb_save_time_start; /* Current RDB save start time. */
// bgsav 调度状态,为1时才能进行bgsave
int rdb_bgsave_scheduled; /* BGSAVE when possible if true. */
// rdb 执行类型,本地rdb持久化还是通过socket进行对外发送
int rdb_child_type; /* Type of save by active child. */
// 上一次执行bgsav的状态
int lastbgsave_status; /* C_OK or C_ERR */
// 如果上一次bgsave失败,增redis不在支持数据写入
int stop_writes_on_bgsave_err; /* Don't allow writes if can't BGSAVE */
...
};
复制代码
saveparam 结构体主要用来保存配置文件中 save 设置的值,可以根据需要设置成多组,默认为三组
struct saveparam {
time_t seconds; // 秒数
int changes; // 修改次数
};
复制代码
四、redis 中 RDB 代码执行流程
下图为 redis 涉及到生成 RDB 命令与后台函数的对应关系图;
shutdown、flushall、save、bgsave、replicaof、redis 定时任务均会触发 redis 的持久化操作,其中最主要也是执行最频繁的就是 bgsave,其他命令的执行频率相对较低,并且他们在底层调用的逻辑代码都大体相同,差异比较大的就是做从库时使用到无盘复制,本文主要分析关于 redis 的持久化,因此后续代码主要对 bgsave 进行分析;
bgsave 的主要执行流程:
rdbSaveBackground -> rdbSave -> rdbSaveRio -> rename
五、redis 中有关 RDB 的参数及其解释
以下参数在 redis.conf 中进行配置,这些参数都会影响到 rdb 文件的生成;
save 参数是 redis 触发 rdb 持久化的策略,意思是从上次持久化之后在多少秒内发生过多少次键值对的写入,save "" 代表着关闭 rdb 持久化策略,save 策略可以配置多组,默认为三组:save 900 1 、save 300 10 、save 60 10000;stop-writes-on-bgsave-error 默认为启用,表示如果 rdb 持久化失败则 redis 处于只读状态;
rdbcompression 代表采用 LZF 算法压缩 rdb 中的键值对内容;
rdbchecksum 生成 rdb 或者加载 rdb 文件时进行校验;
dbfilename 生成的 rdb 名称;
dir 为存放 rdb 文件目录;
bgsave_cpulist bgsave 子进程优先使用的 cpu 序号;
latency-monitor-threshold 单位为 ms,设置为 0 时代表关闭,开启时记录延迟超过配置值的命令
六、RDB 主要执行函数
rdbSaveBackground 函数中调用 fork() 生成子进程,通过子进程调用 rdbSave 函数来进行 rdb 的持久化操作,主进程在 fork 完成之后记录下统计信息、日志和设置完禁用 rehash 之后继续提供服务,需要注意的时,尽管此时禁用了 rehash, rehahs 仍然有可能被触发,默认负载因子超过 5 时【键值对和 buckets 的比例超过 5】
// 宏定义 返回值
#define C_OK 0
#define C_ERR -1
int rdbSaveBackground(char *filename, rdbSaveInfo *rsi) {
pid_t childpid; // 子进程 进程id
long long start; // fork 开始时对应的微秒值
// 如果已经存在 aof 或者 rdb 子进程 则停止本次rdb操作
if (server.aof_child_pid != -1 || server.rdb_child_pid != -1) return C_ERR;
// 上一次 rdb 开始到本次rdb开始时 键值对的变化次数
server.dirty_before_bgsave = server.dirty;
server.lastbgsave_try = time(NULL); // 将 上一次尝试dbsave的时间设置为当前时间戳
openChildInfoPipe(); // 打开当前进程和子进程之间的进程通信
start = ustime(); // 记录fork 开始时间
// if 条件表达式中通过fork 开启 子进程,此时 当前进程和子进程开启各自不同的工作内容,子进程开启rdb,而当前进程则完成部分统计信息之后正常对外提供服务
// 非阻塞指的是 rdb 过程不阻塞主进程,但是 从开始fork到fork完成这段时间仍然是阻塞的
if ((childpid = fork()) == 0) {
int retval;
/* Child */
closeClildUnusedResourceAfterFork(); // 关闭子进程从父进程中继承过来不需要使用的资源
redisSetProcTitle("redis-rdb-bgsave"); // 设置子进程的名称
retval = rdbSave(filename,rsi); // 将redis 内容保存到 磁盘中 形成rdb 文件
// rdb 文件完成保存之后 对 rdb 过程中的资源消耗进行计算
if (retval == C_OK) {
size_t private_dirty = zmalloc_get_private_dirty(-1);
// COW 消耗的内存
if (private_dirty) {
serverLog(LL_NOTICE,
"RDB: %zu MB of memory used by copy-on-write",
private_dirty/(1024*1024));
}
server.child_info_data.cow_size = private_dirty;
sendChildInfo(CHILD_INFO_TYPE_RDB); // 将子进程 COW 数据发送给父进程
}
exitFromChild((retval == C_OK) ? 0 : 1);// 关闭 子进程,子进程工作内容到此结束
} else { // 当前进程继续运行,子进程进行rdb操作
/* Parent */
server.stat_fork_time = ustime()-start; // fork 函数花费的微秒数
// fork 速率 GB/s
server.stat_fork_rate = (double) zmalloc_used_memory() * 1000000 / server.stat_fork_time / (1024*1024*1024); /* GB per second. */
// 与 redis 配置文件中的 latency-monitor-threshold 参数相关,如果超过配置的值,则记录下本次操作导致了延迟
latencyAddSampleIfNeeded("fork",server.stat_fork_time/1000);
if (childpid == -1) { // 子进程 生成失败,更新统计信息中有关bgsave的状态和打印错误日志
closeChildInfoPipe();
server.lastbgsave_status = C_ERR;
serverLog(LL_WARNING,"Can't save in background: fork: %s",
strerror(errno));
return C_ERR;
}
serverLog(LL_NOTICE,"Background saving started by pid %d",childpid);
server.rdb_save_time_start = time(NULL); // 记录bgsave 开始时间
server.rdb_child_pid = childpid; // 执行rdb的子进程id
server.rdb_child_type = RDB_CHILD_TYPE_DISK; // rdb 文件保存在磁盘上
updateDictResizePolicy(); // 禁止rehash操作
return C_OK;
}
return C_OK; /* unreached */
}
复制代码
rdbSave 函数主要生成临时 rdb 文件并且初始化 rio 文件对象,然后调用 rdbSaveRio 函数来完成 rdb 文件的写入,最后调用 rename 函数原子替换 rdb 临时文件名称和 rdb 配置中设置的文件名称
/* Save the DB on disk. Return C_ERR on error, C_OK on success. */
int rdbSave(char *filename, rdbSaveInfo *rsi) {
char tmpfile[256]; // 定义 rdb 临时文件名称
char cwd[MAXPATHLEN]; /* Current working dir path for error messages. */
FILE *fp;
rio rdb;
int error = 0;
// 生成rdb临时文件名称,"temp-"作为前",".rdb"作为后缀,中间为子进程的进程id
snprintf(tmpfile,256,"temp-%d.rdb", (int) getpid());
fp = fopen(tmpfile,"w"); // 设置文件模式为可写
if (!fp) { // rdb 临时文件打开失败
char *cwdp = getcwd(cwd,MAXPATHLEN); // 获取绝对路径
serverLog(LL_WARNING,
"Failed opening the RDB file %s (in server root dir %s) "
"for saving: %s",
filename,
cwdp ? cwdp : "unknown",
strerror(errno));
return C_ERR;// 返回 -1
}
// 初始化 文件对象IO ;rio 是 redis 抽象的IO层,实现了 缓冲区、文件io和socket io,后续读文件读写均通过rio实现
rioInitWithFile(&rdb,fp);
if (server.rdb_save_incremental_fsync) // 判断是否开启了增量 rdb
rioSetAutoSync(&rdb,REDIS_AUTOSYNC_BYTES);// 缓冲区大小设置为 32MB,超过时写入rdb文件
// 将redis 内容保存到rdb文件中
if (rdbSaveRio(&rdb,&error,RDB_SAVE_NONE,rsi) == C_ERR) {
errno = error;
goto werr;
}
/* Make sure data will not remain on the OS's output buffers */
// 刷新缓冲区
if (fflush(fp) == EOF) goto werr;
if (fsync(fileno(fp)) == -1) goto werr;
if (fclose(fp) == EOF) goto werr;
/* Use RENAME to make sure the DB file is changed atomically only
* if the generate DB file is ok. */
// 将临时rdb 文件更改成配置文件中配置的rdb名称,失败则记录日志并删除临时rdb文件
if (rename(tmpfile,filename) == -1) {
char *cwdp = getcwd(cwd,MAXPATHLEN);
serverLog(LL_WARNING,
"Error moving temp DB file %s on the final "
"destination %s (in server root dir %s): %s",
tmpfile,
filename,
cwdp ? cwdp : "unknown",
strerror(errno));
unlink(tmpfile);
return C_ERR;
}
serverLog(LL_NOTICE,"DB saved on disk");
server.dirty = 0; // rdb 完成,设置此时的更改键值对的数量为 0 ,重新开始计数
server.lastsave = time(NULL); // 记录本次rdb完成时间
server.lastbgsave_status = C_OK; // 记录本次rdb完成状态
return C_OK;
werr:
serverLog(LL_WARNING,"Write error saving DB on disk: %s", strerror(errno));
fclose(fp);
unlink(tmpfile);
return C_ERR;
}
复制代码
rdbSaveRio 函数的主要功能是从 redis 中获取到所有的键值对,并按照 rdb 文件协议将内容写入到 rdb 文件中;
其中获取数据主要是通过 dictNext 函数实现,rdb 文件协议则封装在 rio 对象中
int rdbSaveRio(rio *rdb, int *error, int flags, rdbSaveInfo *rsi) {
dictIterator *di = NULL; // 定义一个字典迭代器,后续从redis中读取所以的键值对
dictEntry *de; // 每一个键值对都保存在 dictEntry 中
char magic[10]; // rdb 文件开头固定设置 ”REDIS“ 加上一个rdb对应的版本号
int j;
uint64_t cksum;
size_t processed = 0;
if (server.rdb_checksum)
rdb->update_cksum = rioGenericUpdateChecksum; // 设置检验函数
snprintf(magic,sizeof(magic),"REDIS%04d",RDB_VERSION);
if (rdbWriteRaw(rdb,magic,9) == -1) goto werr; // 将 magic 写入到 rio中
if (rdbSaveInfoAuxFields(rdb,flags,rsi) == -1) goto werr; /* Save a few default AUX fields with information about the RDB generated. */
/* Iterate over modules, and trigger rdb aux saving for the ones modules types
* who asked for it. */
if (rdbSaveModulesAux(rdb, REDISMODULE_AUX_BEFORE_RDB) == -1) goto werr;
for (j = 0; j < server.dbnum; j++) { // 遍历redis中所有的 db ,默认配置为 16个
redisDb *db = server.db+j;
dict *d = db->dict; // 获取到 redis中hash 表的位置,ht[0] ht[1]
if (dictSize(d) == 0) continue; // 如果hash表中没有键值对 跳过
di = dictGetSafeIterator(d); // 为字典e生成安全迭代器
/* Write the SELECT DB opcode */
// 写入数据库选择标识码,254 ;在rdb文件中 254 表示 执行的是select DB 操作
if (rdbSaveType(rdb,RDB_OPCODE_SELECTDB) == -1) goto werr;
if (rdbSaveLen(rdb,j) == -1) goto werr; // 由于db总数小,因此 用一个字节中的低 6 位来表示db数,高2位表示类型
/* Write the RESIZE DB opcode. We trim the size to UINT32_MAX, which
* is currently the largest type we are able to represent in RDB sizes.
* However this does not limit the actual size of the DB to load since
* these sizes are just hints to resize the hash tables. */
uint64_t db_size, expires_size;
db_size = dictSize(db->dict);// 当前db中的键值对总数
expires_size = dictSize(db->expires); // 当前db中设置了过期时间的键值对总数
if (rdbSaveType(rdb,RDB_OPCODE_RESIZEDB) == -1) goto werr;
if (rdbSaveLen(rdb,db_size) == -1) goto werr;
if (rdbSaveLen(rdb,expires_size) == -1) goto werr;
/* Iterate this DB writing every entry */
while((de = dictNext(di)) != NULL) {// dictNext 函数遍历所有的 buckets,bucket 中存储的键值对以单链表的形式存储
sds keystr = dictGetKey(de); // 获取 key
robj key, *o = dictGetVal(de); // 获取value
long long expire;
initStaticStringObject(key,keystr);// 将获取到的key 保存在栈中 ?avoid bugs like bug #85 ?
expire = getExpire(db,&key); // 获取过期时间
if (rdbSaveKeyValuePair(rdb,&key,o,expire) == -1) goto werr; // 将完整的 key 、value 、 过期时间写入rio对象
/* When this RDB is produced as part of an AOF rewrite, move
* accumulated diff from parent to child while rewriting in
* order to have a smaller final write. */
// aof 混合持久化时使用
if (flags & RDB_SAVE_AOF_PREAMBLE &&
rdb->processed_bytes > processed+AOF_READ_DIFF_INTERVAL_BYTES)
{
processed = rdb->processed_bytes;
aofReadDiffFromParent();
}
}
dictReleaseIterator(di);
di = NULL; /* So that we don't release it again on error. */
}
/* If we are storing the replication information on disk, persist
* the script cache as well: on successful PSYNC after a restart, we need
* to be able to process any EVALSHA inside the replication backlog the
* master will send us. */
// 保存 lua 脚本
if (rsi && dictSize(server.lua_scripts)) {
di = dictGetIterator(server.lua_scripts);
while((de = dictNext(di)) != NULL) {
robj *body = dictGetVal(de);
if (rdbSaveAuxField(rdb,"lua",3,body->ptr,sdslen(body->ptr)) == -1)
goto werr;
}
dictReleaseIterator(di); // 释放迭代器
di = NULL; /* So that we don't release it again on error. */
}
// 所有db 数据写入完成之后,写入一个结束符
if (rdbSaveModulesAux(rdb, REDISMODULE_AUX_AFTER_RDB) == -1) goto werr;
/* EOF opcode */
if (rdbSaveType(rdb,RDB_OPCODE_EOF) == -1) goto werr; // 写入rdb 文件结束符
/* CRC64 checksum. It will be zero if checksum computation is disabled, the
* loading code skips the check in this case. */
cksum = rdb->cksum;// CRC64 checksum
memrev64ifbe(&cksum);
if (rioWrite(rdb,&cksum,8) == 0) goto werr;
return C_OK;
// rdb文件生成过程中出现错误 ,则进行收尾处理
werr:
if (error) *error = errno;
if (di) dictReleaseIterator(di);
return C_ERR;
}
复制代码
七、RDB 文件协议
下图为 rdb 文件结构图,rdb 文件以“REDIS” + “RDB 版本号” 作为文件开始标识,接下来开始记录 redis 本身的属性信息,版本、操作系统位数、开始时间、内存使用大小等,其中还包括 rdb 文件作为从库恢复之后接入主库需要的基础信息,每一个属性键值对之前都会通过 OPCODE 来标识接下来的内容的字段属性,如果使用了扩展模块,记录也和属性信息类似,但是 OPCODE 值不一样。
当所有的基础信息都完成之后,开始遍历 redis 中的所有的 db,每遍历一个 db,都会先记录下 select 对应的 OPCODE 值 FE,接下来的 1 个字节则表示数据库编号,1 个字节的 OPCODE,值为 FB,表示接下来记录当前整个 db 中键值对总数以及设置过期的键有多少个;
接下来按照顺序记录这两个数值
【接下来第一个字节的高 2 位表示接下来需要几个字节来记录长度,然后就可以获取到键值对总数,过期键值总数也是这种方式进行保存】
接下来依次记录 过期时间、类型、键、值,记录时在这些内容之前仍然会加 OPCODE 值以及长度,所有 db 中的数据均写入完成之后,写入结束标识符 FF,最后在写入 8 个字节的校验码,整个 rdb 文件完成;
在 rdb 文件中,记录 len 这个长度时,会通过第一个字节的高 2 为来判断长度需要使用多长的字节来保存长度,当长度小于 64 时,高 2 位为 00,低 6 位就代表实际长度,高 2 位为 01 时代表接下来的 14 位是长度... 不同高 2 位解析出来的长度是不同的
rdb 文件解析示例:
对照着上边 rdb 的结构图我们分析一下 dump.rdb 文件,我们在 redis 中的三个 db 中分别写入了一个值;以下分析时计数为了方便都是从 1 开始;
第一行对应着 rdb 文件的”魔数“,即“REDIS0009”,不用 rdb 版本中的版本号“0009”是会发生变化的
00000000 52 45 44 49 53 30 30 30 39 |REDIS0009
复制代码
接着是操作码 fa,代表着接下来是 redis 属性键值对,有些属性属于可选属性,因此需要特定条件才会写入:
格式是:<opcode len key len value >
00000000 fa 09 72 65 64 69 73 | .redis|
00000010 2d 76 65 72 06 35 2e 30 2e 31 34 fa 0a 72 65 64 |-ver.5.0.14..red|
00000020 69 73 2d 62 69 74 73 c0 40 fa 05 63 74 69 6d 65 |is-bits.@..ctime|
00000030 c2 de 67 5f 62 fa 08 75 73 65 64 2d 6d 65 6d c2 |..g_b..used-mem.|
00000040 40 15 0f 00 fa 0c 61 6f 66 2d 70 72 65 61 6d 62 |@.....aof-preamb|
00000050 6c 65 c0 00 |le
复制代码
属性值写入完成之后,开始循环写入 db 相关信息以及键值对写入格式为:
<opcode dbnum opcod keys_num opcode len key len value...>;00000050 中的第 5、6 两个字节 fe 00,00000060 中的第 5、第 6 个字节 fe 01 ,00000070 中的第 7、第 8 个字节 fe 02 中 fe 是 db 的 opcode 码,0、1、2 分别代表着 db 的序号,操作行为是 select db;00000050 中的第 7、8、9 三个字节 fb 01 00,fb 是键值对总数的 opcode 码,01 代表着当前的 db 下只有一个键值对,00 则表示有 0 个过期键值对,第 10 个字节 00 代表着接下来键值对中的 value 的类型,第 11 个字节 05 代表着 key 的长度,也就是说接下来的五个字节 68 65 6c 6c 6f 就是 key 的内容 “hello”,完成了 key 的解析之后则是 【00000060】 value 的字节长度 03,接下来的三个字节 61 61 69 就是 key 对应的 value 值 “aai”,依次向下解析可以得到 键值对 "hello atome"、"hello ginee"
00000050 fe 00 fb 01 00 00 05 68 65 6c 6c 6f | .......hello|
00000060 03 61 61 69 fe 01 fb 01 00 00 05 68 65 6c 6c 6f |.aai.......hello|
00000070 05 61 74 6f 6d 65 fe 02 fb 01 00 00 05 68 65 6c |.atome.......hel|
00000080 6c 6f 05 67 69 6e 65 65 |lo.ginee
复制代码
文件的最后遇到 ff 代表着文件结束标识,最后 8 个字节则是文件内容校验码
00000080 ff 67 09 f2 9d eb ea ec | .g......|
00000090 bc |.|
复制代码
八、RDB 可能引起的故障
fork 过程对 redis 来说是阻塞的,因此有可能在 rdb 时有“卡顿”现象
rdb 的过程中如果 redis 本身的键值对变化过快,可能导致 rdb 过程中系统内存突增并且伴随着操作系统的缺页中断突增
单机多实例部署 redis 时,rdb 过程可能导致磁盘 IO 负载过高,并且在开启 aof 的情况下导致 redis 响应缓慢
九、结语
RDB 是 redis 数据安全的保障之一,当出现故障时可以通过 rdb 文件快速的恢复出一份数据来,但是 RDB 文件中的数据的时效性不如 AOF;rdb 的生成过程主要封装在 rio 中,通常情况下整个生成 RDB 的过程对主流程是没有影响。
关于领创集团(Advance Intelligence Group)
领创集团成立于 2016 年,致力于通过科技创新的本地化应用,改造和重塑金融和零售行业,以多元化的业务布局打造一个服务于消费者、企业和商户的生态圈。集团旗下包含企业业务和消费者业务两大板块,企业业务包含 ADVANCE.AI 和 Ginee,分别为银行、金融、金融科技、零售和电商行业客户提供基于 AI 技术的数字身份验证、风险管理产品和全渠道电商服务解决方案;消费者业务 Atome Financial 包括亚洲领先的先享后付平台 Atome 和数字金融服务。2021 年 9 月,领创集团宣布完成超 4 亿美元 D 轮融资,融资完成后领创集团估值已超 20 亿美元,成为新加坡最大的独立科技创业公司之一。
往期回顾 BREAK AWAY
Spring data JPA 实践和原理浅析
如何解决海量数据更新场景下的 Mysql 死锁问题
企业级 APIs 安全实践指南 (建议初中级工程师收藏)
Cypress UI 自动化测试框架
serverless让我们的运维更轻松
评论