一.realObject

Redis使用 string list zset hash set 五大数据类型来存储键和值。在每次生成一个键值对时,都会生成两个对象,一个储存键一个储存值。redis定义了RealObject结构体表示他们

typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* lru time (relative to server.lruclock) */
int refcount;
void *ptr;
} robj;

1.type

redis 的对象有五种类型,分别是string list zset hash set,type就是用来标识这五种类型的。

/* Object types */
#define OBJ_STRING 0
#define OBJ_LIST 1
#define OBJ_SET 2
#define OBJ_ZSET 3
#define OBJ_HASH 4

(在redis中,键总是一个字符串对象,而值可以是字符串、列表、集合等对象)

2.编码类型encoding

redis的对象的实际编码方式由encoding参数指定,也就是ptr指针指向的数据以何种底层实现存放。

/* 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 -- 简单动态字符串sds*/
#define OBJ_ENCODING_INT 1 /* Encoded as integer -- long类型的整数*/
#define OBJ_ENCODING_HT 2 /* Encoded as hash table -- 字典dict*/
#define OBJ_ENCODING_ZIPMAP 3 /* Encoded as zipmap -- 3.2.5版本后不再使用 */
#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 -- embstr编码的sds*/
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists -- 由双端链表和压缩列表构成的高速列表*/

可以通过 object encoding key 指令查看值对象的编码

3.访问时间lru

表示该对象最后一次呗访问的时间,占用24bit。保存该值的目的是为了计算对象的空转时长,便于决定是否应该释放该键。

4.引用计数refcount

c语言不具备自动内存回手机制,所以为每个对象设定了引用计数。

  • 当创建一个对象时,记为1;
  • 当被一个新的程序使用时,引用计数++;
  • 不再被一个程序使用时,引用计数--;
  • 当引用计数为0时,释放该对象。回收内存。
void decrRefCount(robj *o) {
// 引用计数为小于等于0,报错
if (o->refcount <= 0) serverPanic("decrRefCount against refcount <= 0");
// 引用计数等于1,减1后为0
// 须要释放整个redis对象
if (o->refcount == 1) {
switch(o->type) {
// 依据对象类型。调用不同的底层函数对对象中存放的数据结构进行释放
case OBJ_STRING: freeStringObject(o); break;
case OBJ_LIST: freeListObject(o); break;
case OBJ_SET: freeSetObject(o); break;
case OBJ_ZSET: freeZsetObject(o); break;
case OBJ_HASH: freeHashObject(o); break;
default: serverPanic("Unknown object type"); break;
}
// 释放redis对象
zfree(o);
} else {
// 引用计数减1
o->refcount--;
}
}

5.ptr指向实际存放的对象

二、OBJ_ENCODING_RAW

RAW编码方式使用简单动态字符串sds来保存字符串对象
struct sdshdr {
unsigned int len; //buf中已经占用的空间的长度
unsigned int free;//buf中剩余的可用空间长度
char buf[];//数据存放位置
};
  • 其中buf[]结束并不依赖于‘\0’,使用len判断结束,可以保存二进制流对象。其中buf[]是一个柔性数组(flexible array member 详见https://blog.csdn.net/sunlylorn/article/details/7544301)
  • 预分配,可以减少修改字符串长度增长时造成的再次分配

三、OBJ_ENCODING_EMBSTR

从Redis 3.0 版本开始,字符串引入了embstr编码方式,长度小于OBJ_ENCONDING_EMBSTR_SIZE_LIMIT的字符串将以EMBSTR方式存储。

EMBSTR方式的意思是 embedded string,字符串的空间将会和redisObject对象的空间一起分配,两者分配在同一个内存块中。

redis中内存分配使用的是jemalloc,jemalloc分配内存的时候是按照8,16,32,64作为块的单位进行飞扑的。为了保证采用这种编码方式的字符串能被jemalloc分配在同一个块(chunk)中,该块长度不能超过64,故字符串长度限制OBJ_ENCONDING_EMBSTR_SIZE_LIMIT = 64 - sizeof('\0') -sizeof(robj) -sizeof(sdshdr) =39.

这样可以有效减少内存分配的次数,提高内存分配的效率,降低碎片率。

结构同样适用 sdshdr(见上文)

四、OBJ_ENCODING_ZIPLIST

链表(list),哈希(hash),有序集合(zset)在成员较少,成员值较小的时候都采用压缩链表(ziplist)编码方式进行存储。

这里成员较少,成员值较小的标准可以通过配置项进行设置

hash-max-ziplist-entries 512
hash-max-ziplist-value 64
list-max-ziplist-entries 512
list-max-ziplist-value 64
zset-max-ziplist-entries 128
zset-max-ziplist-value 64

事实上,ziplist是一个经过特殊编码的双向链表(底层是一个数组),他设计的目的是为了提高存储效率。

一个普通的双向链表,链表中每一项都会占用独立的一块内存,各个项之间用指针连接起来,这样会带来大量的内部碎片(不利于局部性原理缓存加载对应块内存),而且指针也会占用额外的内存。而ziplist将表中每一项存放在前后连续的地址空间内,它其实是一个list而不是一个链表。

area        |<---- ziplist header ---->|<----------- entries ------------->|<-end->|
size 4 bytes 4 bytes 2 bytes ? ? ? ? 1 byte
+---------+--------+-------+--------+--------+--------+--------+-------+
component | zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend |
+---------+--------+-------+--------+--------+--------+--------+-------+
^ ^ ^
address | | |
ZIPLIST_ENTRY_HEAD | ZIPLIST_ENTRY_END
|
ZIPLIST_ENTRY_TAIL area |<------------------- entry -------------------->|
+------------------+----------+--------+---------+
component | pre_entry_length | encoding | length | content |
+------------------+----------+--------+---------+
  • zlbytes:4bytes   表示ziplist占用的字节总数(包括zlbytes本身占用的4个bytes)
  • zltail : 4bytes  表示ziplist表中最优一项(entry)在表中的偏移(字节数)。可以很方便的找到最后一项,从而可以进行O(1)的push和pop
  • zllen : 2bytes  表示ziplist中数据项(entry)的个数。zllen字段因为只有16bit,所以能够表示的最大值就是2^16 -1。如果entry中存放的个数小于等于 2^16 -2 ,那么zllen就表示实际的数据个数,如果zllen为全1(>=2^16 -1),那么数据的个数就需要遍历整个entry(也可以取zltail偏移量计算)。
  • entry : xbytes  表示真正存放数据的数据项,长度不固定。同时,entry也有自己的内部结构,下文会解释。
  • zlend :1bytes  ziplist 的结束标记,固定值等于255(全1)。
  • 其中 zlbytes zltail zllen等值按照低位编址实现。(实际值:0x12345678 高位编址-0x12345678 低位编址-0x78563412)

entry

  • prevrawlen:表示前一个数据项占用的总字节数。这个字段的用处是为了让ziplist能够从后向前遍历(从后一项的位置,秩序要向前便宜prevrawlen个字节,就能够找到前一项)。这个字段采用变长编码。
  • len:表示当前数据项的长度。采用变长编码
  • data:保存数据。

变长编码

prevrawlen: 它有两种可能,要么是1个字节,或者是5个字节

1.如果前面一个数据项字节占用小于等于253,那么prevrawlen就占用一个字节

2.如果前一个entry占用字节数大于等于254,那么第一个字节就是254,后四个字节表示一个整形值,表示prevrawlen的大小。

3.255不出现在entry的第一个字节,因为它表示结束。

len:len字段更加复杂,它根据第一个字节的不同 ,一共分为9种情况。(以下用二进制表示)

1.|00xxxxxx| - 1 byte。第一个字节最高两位是00,那么len字段占1个字节,剩余的6个bit用来表示长度,最多可以表示63.

2.|01xxxxxx|xxxxxxxx| - 2 bytes。 第一个字节最高两位是01,那么len字段占2个字节,剩余的14个bit用来表示长度,最多16383(2^14-1)。

3.|10______|xxxxxxxx|xxxxxxxx|xxxxxxxx|xxxxxxxx| - 5bytes。

第一个字节最高两位是10,len字段占5个字节,总共使用32个bit来表示长度(2-7位舍弃不用),最多表示2^32-1。

(在前三种情况下,data都是按字符串存储的,从下面一种情况开始,开始按整数来存储)。

4.|11000000| - 1 byte。 len字段占用一个字节,后面的data数据储存位2个字节的int16_t类型。

5.|11010000| - 1 byte。 len字段占用一个字节,后面的data存储为4个字节的int32_t类型。

6.|11100000| - 1 byte。 len字段占用一个字节,后面的data存储为8个字节的int64_t类型。

7.|11110000| - 1 byte。 len字段占用一个字节,后面的data存储为3个字节长的整数。

8.|11111110|  - 1 byte。len字段占用一个字节,后面的data存储为1个字节长的整数。

9.|1111xxxx|  - 1byte。这是一种特殊情况,xxxx从1到13一共13个值,就用这13种情况表示真正的数据。(0001-1101一共13个值表示0-12,这种情况下data于len字段合二为一了。)

hash与zipliist

这个ziplist一共包含33个字节。从byte[0]-byte[32],每个字节的值采用16进制表示。

总结一下,这个ziplist里存了4个数据项,分别为:

字符串:name   字符串:tielei

字符串:age     整数:20

其实这个ziplist是通过两个hset命令创建出来的。

hset user:100 name tielei
hset user:100 age

当我们为某个key第一次执行hset key field value时,redis会创建一个hash结构,这个新创建的hash底层就是一个ziplist。

// object.c
robj *createHashObject(void) {
unsigned char *zl = ziplistNew();
robj *o = createObject(OBJ_HASH, zl);
o->encoding = OBJ_ENCODING_ZIPLIST;
return o;
}

 上面的代码负责创建一个新的hash结构,实际上,它创建了一个 type = OBJ_HASH但encoding = OBJ_ENCODING_ZIPLIST的robj对象。

(每当执行一次hset命令,插入的field和value分别作为一个新的数据项插入到ziplist中)。

当然随着数据的插入,hash的层的这个ziplist就可能会转成dict。

ziplist的插入逻辑

在ziplist的entry中插入一段新的数据,会返回一个新的ziplist,替换原来传入的旧的ziplist。因为ziplist是一段连续的地址空间,对他的追加操作,会引发内存的realloc,因此ziplist的内存位置可能会发生变化。

1.先把插入结点后面结点的prevrawlen写入新entry 然后把新结点的长度写入后一个结点的prevrawlen

2.然后计算插入后的空间大小,调用allocator的zrealloc,数据拷贝。

3.然后就是将插入位置后的数据向后挪动,插入新entry。

五、OBJ_ENCODING_LINKEDLIST 和 OBJ_ENCODING_QUICKLIST

在redis 3.2之前 一般的链表采用LINKEDLIST编码。

在redis 3.2版本开始,所有的链表都采用QUICKLIST编码。

两者都是使用基本的双端链表数据结构,区别是QUICKLIST每个结点的值都是使用ZIPLIST进行存储的。

// 3.2版本之前
typedef struct list {
listNode *head;
listNode *tail;
void *(*dup)(void *ptr);
void (*free)(void *ptr);
int (*match)(void *ptr,void *key);
unsigned long len;
} list;
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void *value;
} listNode;
// 3.2版本
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; /* total count of all entries in all ziplists */
unsigned int len; /* number of quicklistNodes */
int fill : 16; /* fill factor for individual nodes */
unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl;
unsigned int sz; /* ziplist size in bytes */
unsigned int count : 16; /* count of items in ziplist */
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /* was this node previous compressed? */
unsigned int attempted_compress : 1; /* node can't compress; too small */
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

什么意思呢,比如,一个包含三个结点的quicklist,如果每个结点的ziplist又包含四个数据项,那么对外表现上,这个list就总共包含12个数据项。这样的设计,实际上是对于时间和空间的一种折中。

  • linkedlist便于在表的两端进行push和pop操作,但是它的内存开销较大。首先,它的每个节点除了要保存数据之外还要额外保存两个指针;其次,双向链表的各个节点是单独的内存块,地址不连续,容易产生内存碎片,还容易造成抖动。
  • ziplist由于是一整块连续的内存,存储效率很高,但不利于添加和删除操作,每次都会重新realloc,尤其是当ziplist很长的时候,一次realloc造成的开销特别的大,查询的开销也特别的大。

于是quicklist集合了两个结构的有点,但多少是合理的长度呢,redis系统中用户可以自定义这个值。

list-max-ziplist-size -2

这个参数可正可负,取正值 n 的时候,这个正值表示的就是每个ziplist的长度最多不能超过n。

当取复制的时候 只能取 -1 ~ -5 这五个值,表示按照字节数来限定每个ziplist节点的长度。

  • -1 每个quicklist节点大小不能超过4Kb
  • -2 每个quicklist节点大小不能超过8Kb
  • -3 每个quicklist节点大小不能超过16Kb
  • -4 每个quicklist节点大小不能超过32Kb
  • -5 每个quicklist节点大小不能超过64Kb

节点的压缩

另外,quicklist的设计目标是用来存储很长的数据链表的,当链表很长的时候,最容易被访问的是两端的数据,中间的数据被访问的概率比较低,如果应用场景符合这个特点,list还提供了一个选项,可以把中间的数据节点进行压缩,而两边不被压缩,参数

list-compress-depth 0

就是用来完成这个设置的,数值表示两边不被压缩的节点个数

  • 其中 0 是个特殊值,表示都不压缩,这是 redis的默认值。
  • 1表示quicklist两端各有1个节点不压缩,中间的节点压缩。
  • 2表示quicklist两端各有2个节点不压缩,中间的节点压缩
  • 3......
  • 对于quicklist的压缩算法,采用LZF---一种无损压缩算法。https://www.cnblogs.com/pengze0902/p/5998843.html

quicklist的数据结构(quicklist.h)

/* quicklistNode is a 32 byte struct describing a ziplist for a quicklist.
* We use bit fields keep the quicklistNode at 32 bytes.
* count: 16 bits, max 65536 (max zl bytes is 65k, so max count actually < 32k).
* encoding: 2 bits, RAW=1, LZF=2.
* container: 2 bits, NONE=1, ZIPLIST=2.
* recompress: 1 bit, bool, true if node is temporarry decompressed for usage.
* attempted_compress: 1 bit, boolean, used for verifying during testing.
* extra: 12 bits, free for future use; pads out the remainder of 32 bits */
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl;
unsigned int sz; /* ziplist size in bytes */
unsigned int count : 16; /* count of items in ziplist */
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /* was this node previous compressed? */
unsigned int attempted_compress : 1; /* node can't compress; too small */
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode; /* quicklistLZF is a 4+N byte struct holding 'sz' followed by 'compressed'.
* 'sz' is byte length of 'compressed' field.
* 'compressed' is LZF data with total (compressed) length 'sz'
* NOTE: uncompressed length is stored in quicklistNode->sz.
* When quicklistNode->zl is compressed, node->zl points to a quicklistLZF */
typedef struct quicklistLZF {
unsigned int sz; /* LZF size in bytes*/
char compressed[];
} quicklistLZF; /* quicklist is a 32 byte struct (on 64-bit systems) describing a quicklist.
* 'count' is the number of total entries.
* 'len' is the number of quicklist nodes.
* 'compress' is: -1 if compression disabled, otherwise it's the number
* of quicklistNodes to leave uncompressed at ends of quicklist.
* 'fill' is the user-requested (or default) fill factor. */
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; /* total count of all entries in all ziplists */
unsigned int len; /* number of quicklistNodes */
int fill : 16; /* fill factor for individual nodes */
unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;

quicklistNode结构代表quicklist的一个节点

  • prev 指向前一个节点
  • next 指向后一个节点
  • zl     数据指针。如果当前节点没有被压缩,它指向一个ziplist;否则,它指向一个quicklistLZF结构
  • sz    表示zl指向的ziplist的总大小,如果他被压缩了,那么它指压缩前的ziplist大小。
  • count表示ziplist里面包含的数据项的个数,
  • encoding 表示ziplist是否被压缩
  • container 保留字段,表示一个quicklistNode是直接存数据,还是使用ziplist存数据,或者采用其他结构类存数据。但是在目前实现中是一个固定值2,表示ziplist存数据
  • recompress 这个节点之前被压缩没有,当我们使用lindex这样的命令查看某一项本来被压缩的数据时,需要把数据暂时解压,这时就设置recompress=1  做一个标记,等空闲时候再次把数据重新压缩。
  • attemped_compress
  • extra    其他扩展字段 目前弃用

quicklistLZF结构表示一个被压缩的ziplist。

  • sz 表示压缩后的ziplist大小
  • compress 柔性数组(flexible array member) 存放数据压缩后的ziplist字节数组

quicklist是真正的保存quicklist的结构

  • count 所有ziplist数据项的总和
  • len quicklistNode节点的个数
  • fill ziplist大小的摄者 存放 list-max-ziplist-size
  • compress 节点压缩深度 存放 list-compress-depth

上图为一个quicklist结构图 对应的fill和compress 配置 为

list-max-ziplist-size 3
list-compress-depth 2

 其中左右两端各有两个黄色节点,是没有被压缩的,他们的数据指针zl直接指向ziplist结构。中间的蓝色节点是lzf压缩过的,zl指针指向quicklistLZF结构。

左侧头结点上的ziplist有两项数据,右侧头结点有1项。

quicklist插入

  • 头尾节点插入时,如果对应节点的ziplist大小没有超过限制,则新数据直接被插入对应ziplist;如果超过限制,那么新建一个quicklistNode节点,把待插入节点插入新的ziplist中。
  • 如果插入的位置是链表中间部分,当插入位置所在的ziplist没达到大小限制,直接插入对应ziplist;
  • 当所在的ziplist大小超过了限制,但插入的位置在ziplist两端,如果相邻节点的ziplist没有超过大小限制,那么就插入相邻节点
  • 如果相邻节点的大小超过了限制,那么新建一个quicklistNode,插入对应节点
  • 对其他情况,需要吧当前ziplist分裂成两个节点,然后在其中一个节点中插入数据。

Redis 数据结构的底层实现 (一) RealObject,embstr,sds,ziplist,quicklist的更多相关文章

  1. Redis 数据结构的底层实现 (二) dict skiplist intset

    一.REDIS_INCODING_HT (dict字典,hashtable) dict是一个用于维护key和value映射关系的数据结构.redis的一个database中所有的key到value的映 ...

  2. Redis数据结构底层知识总结

    Redis数据结构底层总结 本篇文章是基于作者黄建宏写的书Redis设计与实现而做的笔记 数据结构与对象 Redis中数据结构的底层实现包括以下对象: 对象 解释 简单动态字符串 字符串的底层实现 链 ...

  3. 面试官:你看过Redis数据结构底层实现吗?

    面试中,redis也是很受面试官亲睐的一部分.我向在这里讲的是redis的底层数据结构,而不是你理解的五大数据结构.你有没有想过redis底层是怎样的数据结构呢,他们和我们java中的HashMap. ...

  4. Redis(一) 数据结构与底层存储 & 事务 & 持久化 & lua

    参考文档:redis持久化:http://blog.csdn.net/freebird_lb/article/details/7778981 https://blog.csdn.net/jy69240 ...

  5. Redis 数据结构与内存管理策略(上)

    Redis 数据结构与内存管理策略(上) 标签: Redis Redis数据结构 Redis内存管理策略 Redis数据类型 Redis类型映射 Redis 数据类型特点与使用场景 String.Li ...

  6. 5种Redis数据结构详解

    本文主要和大家分享 5种Redis数据结构详解,希望文中的案例和代码,能帮助到大家. 转载链接:https://www.php.cn/php-weizijiaocheng-388126.html 2. ...

  7. Redis数据结构和对象三

    1.Redis 对象系统 Redis用到的所有主要数据结构,简单动态字符串(SDS).双端链表.字典.压缩列表.整数集合.跳跃表. Redis并没有直接使用这些数据结构来实现键值对数据库,而是基于这些 ...

  8. 选择合适Redis数据结构,减少80%的内存占用

    redis作为目前最流行的nosql缓存数据库,凭借其优异的性能.丰富的数据结构已成为大部分场景下首选的缓存工具. 由于redis是一个纯内存的数据库,在存放大量数据时,内存的占用将会非常可观.那么在 ...

  9. 高性能的Redis之对象底层实现原理详解

    对象 在前面的数个章节里, 我们陆续介绍了 Redis 用到的所有主要数据结构, 比如简单动态字符串(SDS).双端链表.字典.压缩列表.整数集合, 等等. Redis 并没有直接使用这些数据结构来实 ...

随机推荐

  1. 剑指offer-面试题41-数据流中的中位数-堆

    /* 题目: 链接:https://www.nowcoder.com/questionTerminal/9be0172896bd43948f8a32fb954e1be1 来源:牛客网 如何得到一个数据 ...

  2. 深度优先搜索DFS---01背包问题(1)

    题目: 有n件物品,每件物品的重量为w[i],价值为c[i].现在需要选出若干件物品放入一个容量为 V 的背包中,使得在选入背包的物品重量之和,不超过容量V的前提下,让北欧保重物品的价值之和最大,求最 ...

  3. python如何安装Jupyter notebook

    一,安装Jupyter notebook 环境:win10,python3.7 两种安装方式,这里只讲pip安装 pip install jupyter notebook 二,启动Jupyter no ...

  4. Miller-Rabin素数检测算法 acm模板

    Miller-Rabin素数检测算法 其基于以下两个定理. Fermat小定理 若n是素数,则∀a(a̸≡0(modn))\forall a(a \not\equiv 0 \pmod{n})∀a(a̸ ...

  5. LeetCode 9、判断一个整数是否是回文数。回文数是指正序(从左向右)和倒序(从右向左)读都是一样的整数。

    class Solution: def isPalindrome(self, x: int) -> bool: a = x if a<0: return False else: num = ...

  6. Selenium实战(二)——调用JavaScript之execute_script()方法

    1.浏览器滚动条的拖动,不能依靠WebDriver提供的API来实现,用于调整浏览器滚动条位置的JavaScript代码如下: window.scrollTo(0,450); window.scrol ...

  7. ubuntu---禁止更新内核

    系统内核 4.15.0-29  更新成了 4.15.0-88,降级内核并禁止更新内核 查看已安装内核:dpkg --get-selections |grep linux-image 查看正在使用的内核 ...

  8. 布隆过滤器--guava实现

    private static int size = 1000000;//预计要插入多少数据 private static double fpp = 0.01;//期望的误判率 private stat ...

  9. Linux下安装python,mysql,redis

    linux 安装Python3 1.python下载 请在终端输入如下命令: cd /home wget http://cdn.npm.taobao.org/dist/python/3.6.5/Pyt ...

  10. 创建基于ASP.NET core 3.1 的RazorPagesMovie项目(三)-已搭建基架的Razor页面解释和更新

    本节主要介绍在上一节中通过搭建基架而创建的Razor页面,并做一些UI改变. 一.创建.删除.详细信息和编辑页面 1.双击Pages/Movies/Index.cshtml.cs文件,这是一个Razo ...