本文及后续文章,Redis版本均是v3.2.8

上篇文章介绍了RDB的优缺点,我们先来回顾下RDB的主要原理,在某个时间点把内存中所有数据保存到磁盘文件中,这个过程既可以通过人工输入命令执行,也可以让服务器周期性执行。

RDB持久化机制RDB的实现原理,涉及的文件为rdb.hrdb.c

一、初始RDB

先在Redis客户端中执行以下命令,存入一些数据:

127.0.0.1:6379> flushdb

OK

127.0.0.1:6379> set city "beijing"

OK

127.0.0.1:6379> save

OK

127.0.0.1:6379>

Redis提供了save和bgsave两个命令来生成RDB文件(即将内存数据写入RDB文件中),执行成功后我们在磁盘中找到该RDB文件(dump.rdb),该文件存放的内容如下:

REDIS0007?redis-ver3.2.100?redis-bits繞?ctime聥阨Y?used-mem锣?

我们再来看下Redis Server的版本号

RDB文件中存放的是二进制数据,从上面的文件非乱码的内容中我们大概可以看出里面存放的各个类型的数据信息。下面我们就来介绍一下RDB的文件格式。

二、RDB文件结构

我们先大致看下RDB文件结构

1、RDB文件结构

我们看下图中的各部分含义:

名称 大小 说明
REDIS 5bytes 固定值,存放’R’,’E’,’D’,’I’,’S’
RDB_VERSION 4bytes

RDB版本号,在rdb.h头文件中定义

/* The current RDB version. When the format changes in a way that is no longer backward compatible this number gets incremented. */

#define RDB_VERSION 7

DB-DATA —— 存储真正的数据
RDB_OPCODE_EOF 1byte

255(0377),表述数据库结束,

在rdb.h头文件中定义

#define RDB_OPCODE_EOF

255

checksum —— 校验和

2、DB-DATA结构

名称 大小 说明
RDB_OPCODE_SELECTDB 1byte

以前我们介绍过,当redis 服务器初始化时,会预先分配 16 个数据库。这里我们需要将非空的数据库信息保存在RDB文件中。

在rdb.h头文件中定义

#define RDB_OPCODE_SELECTDB   254

db_number

1,2,5bytes

存储数据库的号码。

db编号即对应的数据库编号,每个db编号后边到下一个RDB_OPCODE_SELECTDB标识符出现之前的所有数据都是该db下的数据。在REDIS加载 RDB 文件时,会根据这个域的值切换到相应的数据库,以确保数据被还原到正确的数据库中去。

key_value_pairs —— 主要数据

3、key_value_pairs结构

  • 带过期时间

名称 大小 说明
RDB_OPCODE_EXPIRETIME_MS 1byte 252,说明是带过期时间的键值对
timestamp 8bytes 以毫秒为单位的时间戳
TYPE 8bytes 以毫秒为单位的时间戳
key ———
value ———
  • 不带过期时间

名称 大小 说明
TYPE 8bytes 以毫秒为单位的时间戳
key ———
value ———

TYPE的值,目前Redis主要有以下数据类型:

/* Dup object types to RDB object types. Only reason is readability (are we

* dealing with RDB types or with in-memory object types?). */

#define RDB_TYPE_STRING 0

#define RDB_TYPE_LIST   1

#define RDB_TYPE_SET    2

#define RDB_TYPE_ZSET   3

#define RDB_TYPE_HASH   4

/* NOTE: WHEN ADDING NEW RDB TYPE, UPDATE rdbIsObjectType() BELOW */

/* Object types for encoded objects. */

#define RDB_TYPE_HASH_ZIPMAP    9

#define RDB_TYPE_LIST_ZIPLIST  10

#define RDB_TYPE_SET_INTSET    11

#define RDB_TYPE_ZSET_ZIPLIST  12

#define RDB_TYPE_HASH_ZIPLIST  13

#define RDB_TYPE_LIST_QUICKLIST 14

/* NOTE: WHEN ADDING NEW RDB TYPE, UPDATE rdbIsObjectType() BELOW */

4、RDB_OPCODE_EOF

标识数据库部分的结束符,定义在rdb.h文件中:

#define RDB_OPCODE_EOF        255

5、rdb_checksum

RDB 文件所有内容的校验和, 一个 uint_64t 类型值。

Redis在写入RDB文件时将校验和保存在RDB文件的末尾, 当读取RDB时, 根据它的值对内容进行校验。

如果Redis未开启校验功能,则该域的值为0。

#define CONFIG_DEFAULT_RDB_CHECKSUM 1

三、长度编码

在RDB文件中有很多地方需要存储长度信息,如字符串长度、list长度等等。如果使用固定的int或long类型来存储该信息,在长度值比较小的时候会造成较大的空间浪费。为了节省空间,Redis设计了一套特殊的方法对长度进行编码后再存储。我们先来看下定义的编码说明:

/* Defines related to the dump file format. To store 32 bits lengths for short

* keys requires a lot of space, so we check the most significant 2 bits of

* the first byte to interpreter the length:

*

* 00|000000 => if the two MSB are 00 the len is the 6 bits of this byte

* 01|000000 00000000 =>  01, the len is 14 byes, 6 bits + 8 bits of next byte

* 10|000000 [32 bit integer] => if it's 01, a full 32 bit len will follow

* 11|000000 this means: specially encoded object will follow. The six bits

*           number specify the kind of object that follows.

*           See the RDB_ENC_* defines.

*

* Lengths up to 63 are stored using a single byte, most DB keys, and may

* values, will fit inside. */

编码方式 占用字节数 说明
00|000000 1byte 这一字节的其余 6 位表示长度,可以保存的最大长度是 63 (包括在内)
01|000000 00000000 2byte 长度为 14 位,当前字节 6 位,加上下个字节 8 位
10|000000 [32 bit integer] 5byte 长度由随后的 32 位整数保存
11|000000   后跟一个特殊编码的对象。字节中的 6 位(实际上只用到两个bit)指定对象的类型,用来确定怎样读取和解析接下来的数据

rdb.h文件中具体定义的编码:

  • 普通编码方式

#define RDB_6BITLEN 0

#define RDB_14BITLEN 1

#define RDB_32BITLEN 2

#define RDB_ENCVAL 3

表格中前三种可以理解为普通编码方式。

  • 字符串编码方式

/* When a length of a string object stored on disk has the first two bits

* set, the remaining two bits specify a special encoding for the object

* accordingly to the following defines: */

#define RDB_ENC_INT8 0        /* 8 bit signed integer */

#define RDB_ENC_INT16 1       /* 16 bit signed integer */

#define RDB_ENC_INT32 2       /* 32 bit signed integer */

#define RDB_ENC_LZF 3         /* string compressed with FASTLZ */

表格中最后一种可以理解为字符串编码方式。

1、字符串转换为整数进行存储

/* String objects in the form "2391" "-100" without any space and with a

* range of values that can fit in an 8, 16 or 32 bit signed value can be

* encoded as integers to save space */

int rdbTryIntegerEncoding(char *s, size_t len, unsigned char *enc) {

long long value;

char *endptr, buf[32];

/* Check if it's possible to encode this value as a number */

value = strtoll(s, &endptr, 10);

if (endptr[0] != '\0') return 0;

ll2string(buf,32,value);

/* If the number converted back into a string is not identical

* then it's not possible to encode the string as integer */

if (strlen(buf) != len || memcmp(buf,s,len)) return 0;

return rdbEncodeInteger(value,enc);

}

该函数最后调用的rdbEncodeInteger函数是真正完成特殊编码的地方,具体定义如下:

/* Encodes the "value" argument as integer when it fits in the supported ranges

* for encoded types. If the function successfully encodes the integer, the

* representation is stored in the buffer pointer to by "enc" and the string

* length is returned. Otherwise 0 is returned. */

int rdbEncodeInteger(long long value, unsigned char *enc) {

if (value >= -(1<<7) && value <= (1<<7)-1) {

enc[0] = (RDB_ENCVAL<<6)|RDB_ENC_INT8;

enc[1] = value&0xFF;

return 2;

} else if (value >= -(1<<15) && value <= (1<<15)-1) {

enc[0] = (RDB_ENCVAL<<6)|RDB_ENC_INT16;

enc[1] = value&0xFF;

enc[2] = (value>>8)&0xFF;

return 3;

} else if (value >= -((long long)1<<31) && value <= ((long long)1<<31)-1) {

enc[0] = (RDB_ENCVAL<<6)|RDB_ENC_INT32;

enc[1] = value&0xFF;

enc[2] = (value>>8)&0xFF;

enc[3] = (value>>16)&0xFF;

enc[4] = (value>>24)&0xFF;

return 5;

} else {

return 0;

}

}

2、使用lzf算法进行字符串压缩

当Redis开启了字符串压缩的功能后,如果一个字符串的长度超过20bytes,Redis会使用lzf算法对其进行压缩后再存储。

/* Save a string object as [len][data] on disk. If the object is a string

* representation of an integer value we try to save it in a special form */

ssize_t rdbSaveRawString(rio *rdb, unsigned char *s, size_t len) {

int enclen;

ssize_t n, nwritten = 0;

/* Try integer encoding */

if (len <= 11) {

unsigned char buf[5];

if ((enclen = rdbTryIntegerEncoding((char*)s,len,buf)) > 0) {

if (rdbWriteRaw(rdb,buf,enclen) == -1) return -1;

return enclen;

}

}

/* Try LZF compression - under 20 bytes it's unable to compress even

* aaaaaaaaaaaaaaaaaa so skip it */

if (server.rdb_compression && len > 20) {

n = rdbSaveLzfStringObject(rdb,s,len);

if (n == -1) return -1;

if (n > 0) return n;

/* Return value of 0 means data can't be compressed, save the old way */

}

/* Store verbatim */

if ((n = rdbSaveLen(rdb,len)) == -1) return -1;

nwritten += n;

if (len > 0) {

if (rdbWriteRaw(rdb,s,len) == -1) return -1;

nwritten += len;

}

return nwritten;

}

四、value存储

上面我们介绍了长度编码,接下来继续介绍不同数据类型的value是如何存储的?

我们在介绍redisobject《Redis数据结构之robj》时,介绍了对象的10种编码方式。

/* Objects encoding. Some kind of objects like Strings and Hashes can be

* internally represented in multiple ways. The 'encoding' field of the object

* is set to one of this fields for this object. */

#define OBJ_ENCODING_RAW 0     /* Raw representation */

#define OBJ_ENCODING_INT 1     /* Encoded as integer */

#define OBJ_ENCODING_HT 2      /* Encoded as hash table */

#define OBJ_ENCODING_ZIPMAP 3  /* Encoded as zipmap */

#define OBJ_ENCODING_LINKEDLIST 4 /* Encoded as regular linked list */

#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */

#define OBJ_ENCODING_INTSET 6  /* Encoded as intset */

#define OBJ_ENCODING_SKIPLIST 7  /* Encoded as skiplist */

#define OBJ_ENCODING_EMBSTR 8  /* Embedded sds string encoding */

#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */

1、string类型对象

我们知道,字符串类型对象的存储结构是RDB文件中最基础的存储结构,其它数据类型的存储大多建立在字符串对象存储的基础上。

  • OBJ_ENCODING_INT编码的字符串

对于REDIS_ENCODING_INT编码的字符串对象,有以下两种保存方式:

a、如果该字符串可以用 8 bit、 16 bit或 32 bit长的有符号整型数值表示,那么就直接以整型数保存;

b、如果32bit的整数无法表示该字符串,则该字符串是一个long long类型的数,这种情况下将其转化为字符串后存储。

对于第一种方式,value域就是一个整型数值;

对于第二种方式,value域的结构为:

其中length域存放字符串的长度,content域存放字符序列。

/* Save a long long value as either an encoded string or a string. */

ssize_t rdbSaveLongLongAsStringObject(rio *rdb, long long value) {

unsigned char buf[32];

ssize_t n, nwritten = 0;

int enclen = rdbEncodeInteger(value,buf);

if (enclen > 0) {

return rdbWriteRaw(rdb,buf,enclen);

} else {

/* Encode as string */

enclen = ll2string((char*)buf,32,value);

serverAssert(enclen < 32);

if ((n = rdbSaveLen(rdb,enclen)) == -1) return -1;

nwritten += n;

if ((n = rdbWriteRaw(rdb,buf,enclen)) == -1) return -1;

nwritten += n;

}

return nwritten;

}

  • OBJ_ENCODING_RAW编码的字符串

对于REDIS_ENCODING_RAW编码的字符串对象,有以下三种保存方式:

a、如果该字符串可以用 8 bit、 16 bit或 32 bit长的有符号整型数值表示,那么就将字符串转换为整型数存储以节省空间;

b、如果服务器开启了字符串压缩功能,且该字符串的长度大于20bytes,则使用lzf算法对字符串压缩后进行存储;

c、如果不满足上面两个条件,Redis只能以普通字符序列的方式来保存该字符串字符串对象。

对于前面两种方式,详见小节【长度编码】中已经详细介绍过。

对于第三种方式,Redis以普通字符序列的方式来保存字符串对象,value域的存储结构为:

其中length域存放字符串的长度,content域存放字符串本身。

/* Save a string object as [len][data] on disk. If the object is a string

* representation of an integer value we try to save it in a special form */

ssize_t rdbSaveRawString(rio *rdb, unsigned char *s, size_t len) {

int enclen;

ssize_t n, nwritten = 0;

/* Try integer encoding */

if (len <= 11) {

unsigned char buf[5];

if ((enclen = rdbTryIntegerEncoding((char*)s,len,buf)) > 0) {

if (rdbWriteRaw(rdb,buf,enclen) == -1) return -1;

return enclen;

}

}

/* Try LZF compression - under 20 bytes it's unable to compress even

* aaaaaaaaaaaaaaaaaa so skip it */

if (server.rdb_compression && len > 20) {

n = rdbSaveLzfStringObject(rdb,s,len);

if (n == -1) return -1;

if (n > 0) return n;

/* Return value of 0 means data can't be compressed, save the old way */

}

/* Store verbatim */

if ((n = rdbSaveLen(rdb,len)) == -1) return -1;

nwritten += n;

if (len > 0) {

if (rdbWriteRaw(rdb,s,len) == -1) return -1;

nwritten += len;

}

return nwritten;

}

2、list类型对象

  • OBJ_ENCODING_LINKEDLIST编码的list类型对象

每个节点以字符串对象的形式逐一存储。

在RDB文件中存储结构如下:

  • OBJ_ENCODING_ZIPLIST编码的list类型对象

Redis将其当做一个字符串对象的形式进行保存。

3、hash类型对象

  • OBJ_ENCODING_ZIPLIST编码的hash类型对象

Redis将其当做一个字符串对象的形式进行保存。

  • OBJ_ENCODING_HT编码的hash类型对象

hash中的每个键值对的key值和value值都以字符串对象的形式相邻存储。

在RDB文件中存储结构如下:

4、set类型对象

  • OBJ_ENCODING_HT编码的set类型对象

其底层使用字典dict结构进行存储,只是该字典的value值为NULL,所以只需要存储每个键值对的key值即可。每个元素以字符串对象的形式逐一存储。

在RDB文件中存储结构如下:

  • OBJ_ENCODING_INTSET编码的set类型对象

Redis将其当做一个字符串对象的形式进行保存,

5、zset类型对象

  • OBJ_ENCODING_ZIPLIST编码的zset类型对象

Redis将其当做一个字符串对象的形式进行保存。

  • OBJ_ENCODING_QUICKLIST编码的zset类型对象

对于其中一个元素,先存储其元素值value再存储其分值score。zset的元素值是一个字符串对象,按字符串形式存储,分值是一个double类型的数值,Redis先将其转换为字符串对象再存储。

在RDB文件中存储结构如下:

五、RDB如何完成存储

  • save命令

save是在Redis进程中执行的,由于Redis是单线程实现,所以当save命令在执行时会阻塞Redis服务器一直到该命令执行完成为止。

  • bgsave命令

与save命令不同的是,bgsave命令会先fork出一个子进程,然后在子进程中生成RDB文件。由于在子进程中执行IO操作,所以bgsave命令不会阻塞Redis服务器进程,Redis服务器进程在此期间可以继续对外提供服务。

bgsave命令由rdbSaveBackground函数实现,从该函数的实现中可以看出:为了提高性能,Redis服务器在bgsave命令执行期间会拒绝执行新到来的其它bgsave命令。

这里就不再列出rdbSave函数和rdbSaveBackground函数的具体实现,请移步到rdb.c文件中查看。

上篇文章《Redis持久化persistence》中,介绍了redis.conf中配置"触发执行"的配置:

<seconds> <changes>

表示如果在secons指定的时间(秒)内对Redis数据库DB至少进行了changes次修改,则执行一次bgsave命令

我们思考一个问题:Redis是如何判断save选项配置条件是否已经达到,可以触发执行的呢?

1、save选项配置条件如何存储?

在server.h头文件中,定义了saveparam结构体来保存save配置选项,该结构体的定义如下:

struct saveparam {

time_t seconds; // 秒数

int changes;      // 修改次数

};

Redis默认提供或用户输入的save选项则保存在 redisServer结构体中:

struct redisServer {

....

/* RDB persistence */

long long dirty;                /* Changes to DB from the last save */

long long dirty_before_bgsave;  /* Used to restore dirty on failed BGSAVE */

pid_t rdb_child_pid;            /* PID of RDB saving child */

struct saveparam *saveparams;   /* Save points array for RDB */

int saveparamslen;              /* Number of saving points */

char *rdb_filename;             /* Name of RDB file */

int rdb_compression;            /* Use compression in RDB? */

int rdb_checksum;               /* Use RDB checksum? */

time_t lastsave;                /* Unix time of last successful save */

time_t lastbgsave_try;          /* Unix time of last attempted bgsave */

time_t rdb_save_time_last;      /* Time used by last RDB save run. */

time_t rdb_save_time_start;     /* Current RDB save start time. */

int rdb_bgsave_scheduled;       /* BGSAVE when possible if true. */

int rdb_child_type;             /* Type of save by active child. */

int lastbgsave_status;          /* C_OK or C_ERR */

int stop_writes_on_bgsave_err;  /* Don't allow writes if can't BGSAVE */

int rdb_pipe_write_result_to_parent; /* RDB pipes used to return the state */

int rdb_pipe_read_result_from_child; /* of each slave in diskless SYNC. */

....

}

我们可以看到redisServer结构体中的saveparams字段是一个数组,里面一个元素就是一个save配置,而saveparamslen字段则指明了save配置的个数。

2、修改的次数和时间记录如何存储?

我们从redisServer结构体中,知道dirty和lastsave字段

  • dirty的值表示自最近一次执行save或bgsave以来对数据库DB的修改(即执行写入、更新、删除操作的)次数;

  • lastsave是最近一次成功执行save或bgsave命令的时间戳。

 

3、Redis如何判断是否满足save选项配置的条件?

到目前为止,我们已经有了记录save配置的redisServer.saveparams数组,告诉Redis如果满足save配置的条件则执行一次bgsave命令。此外我们也有了redisServer.dirty和redisServer.lastsave两个字段,分别记录了对数据库DB的修改(即执行写入、更新、删除操作的)次数和最近一次执行save或bgsave命令的时间戳。

接下来我们只要周期性地比较一下redisServer.saveparams和redisServer.dirty、redisServer.lastsave就可以判断出是否需要执行bgsave命令。

这个周期性执行检查功能的函数就是serverCron函数,定义在server.c文件中。

六、总结

  • rdbSave 会将数据库数据保存到 RDB 文件,并在保存完成之前阻塞调用者。

  • SAVE 命令直接调用 rdbSave ,阻塞 Redis 主进程; BGSAVE 用子进程调用 rdbSave ,主进程仍可继续处理命令请求。

  • SAVE 执行期间, AOF 写入可以在后台线程进行, BGREWRITEAOF 可以在子进程进行,所以这三种操作可以同时进行。

  • 为了避免产生竞争条件, BGSAVE 执行时, SAVE 命令不能执行。

  • 为了避免性能问题, BGSAVE 和 BGREWRITEAOF 不能同时执行。

  • RDB 文件使用不同的格式来保存不同类型的值。

--EOF--

Redis持久化之RDB的更多相关文章

  1. Redis 持久化之RDB和AOP

    Redis 持久化之RDB和AOP Redis 有两种持久化方案,RDB (Redis DataBase)和 AOP (Append Only File).如果你先快速了解和使用RDB和AOP,可以直 ...

  2. Redis 持久化之RDB和AOF

    Redis 持久化之RDB和AOF Redis 有两种持久化方案,RDB (Redis DataBase)和 AOF (Append Only File).如果你想快速了解和使用RDB和AOF,可以直 ...

  3. 详解Redis持久化(RDB和AOF)

    详解Redis持久化(RDB和AOF) 什么是Redis持久化? Redis读写速度快.性能优越是因为它将所有数据存在了内存中,然而,当Redis进程退出或重启后,所有数据就会丢失.所以我们希望Red ...

  4. redis持久化(RDB、AOF、混合持久化)

    redis持久化(RDB.AOF.混合持久化) 1. RDB快照(snapshot) 在默认情况下, Redis 将内存数据库快照保存在名字为 dump.rdb 的二进制文件中. 你可以对 Redis ...

  5. Redis学习——Redis持久化之RDB备份方式保存数据

    从这一个介绍里面知道,redis比memcache作为缓存数据库强大的地方,一个是支持的数据类型比较多,另一个就是redis持久化功能. 下面就介绍Redis的持久化之RDB! 一:什么是redis的 ...

  6. redis 持久化之 RDB

    redis的运维过程中,我们对数据持久化做一个基本的总结. 1什么是持久化: redis 所有数据保持在内存中,对数据的更新将异步地保存到磁盘上. RDB 文件创建的过程是直接从内存 写入到我们我磁盘 ...

  7. Redis持久化之RDB&&AOF的区别

    在说Redis持久化之前,需要搞明白什么是数据库状态这个概念,因为持久化的就是将内存中的数据库状态保存到磁盘上.那么什么是数据库状态呢?Redis是一个key-value数据库服务器,一般默认是有16 ...

  8. 【Redis】Redis学习(七) Redis 持久化之RDB和AOF

    Redis 持久化提供了多种不同级别的持久化方式:一种是RDB,另一种是AOF. RDB 持久化可以在指定的时间间隔内生成数据集的时间点快照(point-in-time snapshot). AOF ...

  9. redis——持久化方式RDB与AOF分析

    https://blog.csdn.net/u014229282/article/details/81121214 redis两种持久化的方式 RDB持久化可以在指定的时间间隔内生成数据集的时间点快照 ...

随机推荐

  1. (转)ReentrantLock实现原理及源码分析

    背景:ReetrantLock底层是基于AQS实现的(CAS+CHL),有公平和非公平两种区别. 这种底层机制,很有必要通过跟踪源码来进行分析. 参考 ReentrantLock实现原理及源码分析 源 ...

  2. Linux 内核文档翻译 - kobject.txt

    原文地址:Linux 内核文档翻译 - kobject.txt 作者:qh997 Everything you never wanted to know about kobjects, ksets, ...

  3. DTW和DBA

    DTW(动态时间调整) 动态时间调整算法是大多用于检测两条语音的相似程度,由于每次发言,每个字母发音的长短不同,会导致两条语音不会完全的吻合,动态时间调整算法,会对语音进行拉伸或者压缩,使得它们竟可能 ...

  4. 微信小程序中-折线图

    echarts配置项太多了,还是一点点积累吧~~~~~ 当然前提条件还是得老老实实看echarts官方文档 :https://echarts.baidu.com/ 今天主要就介绍下我在工作中通过ech ...

  5. Istio

    什么是Istio Istio是Service Mesh(服务网格)的主流实现方案.该方案降低了与微服务架构相关的复杂性,并提供了负载均衡.服务发现.流量管理.断路器.监控.故障注入和智能路由等功能特性 ...

  6. Unity下载

    Unity下载 Mac OS或Windows操作系统都可以使用对应的Unity版本进行开发 Unity引擎的官方网站:http://www.unity3d.com 官方下载地址:http://unit ...

  7. MySQL学习笔记(七)使用AutoMySQLBackup工具自动备份MySQL数据库

    1.下载 wget https://nchc.dl.sourceforge.net/project/automysqlbackup/AutoMySQLBackup/AutoMySQLBackup%20 ...

  8. 五十四、linux 编程——TCP 编程模型

    54.1 编程模型介绍 54.1.1 TCP 客户端服务器编程模型 客户端调用序列 调用 socket 函数创建套接字 调用 connect 连接服务器端 调用 I/O 函数(read/write) ...

  9. php json数据 入库时 转义字符丢失

    转义字符入库后消失,导致出库后无法反转义 解决办法  增加 addslashes函数 if (empty($result)) { $data['activitiesid'] = $param['act ...

  10. 基于Spring注解搭建SpringMVC项目

    在2018寒冬,我下岗了,因为我的左脚先迈进了公司的大门.这不是重点,重点是我扑到了老板小姨子的怀里. 网上好多教程都是基于XML的SpringMVC,想找一篇注解的,但是写的很模糊,我刚好学到这里, ...