Redis 源码:Redis 网络模型、通信协议和内存回收

IO 多路复用

文件描述符(File Descriptor):简称 FD,是一个从 0 开始递增的无符号整数,用来关联 Linux 中的一个文件,在 Linux 中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket

Select

selectlinux 中最早的 I/O 复用实现方案

它的作用就是用来监听多个 fd 的集合

c 复制代码
// 定义类型别名 __fd_mask,本质是 long int
// 占用 4 字节,32 位
typedef long int __fd_mask;
// fd_set 记录要监听的 fd 集合,及其对应状态
typedef struct {
  // fds_bits 是 long 类型数组,长度为 1024/32 = 32
  // 共 1024 个 bit 位,每个 bit 位代表一个 fd,0 代表为就绪,1 代表就绪
  // 也就是说 fds_bits 有 32 个元素,每个元素有 32 bit 位,共 1024 个 bit 位
  __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set
// select 函数,用于监听多个 fd 的集合
int select(
  // 要监视的 fd_set 的最大 fd + 1
  int nfds, // fd 是无符号整数,从 0 开始不断递增,最大 fd + 1 就是告诉内核已经到最大值了,不用在遍历了
  // linux 把一个 IO 可能发生的事件分为三类,分别是读、写、异常
  fd_set *readfds, // 要监听读事件的 fd 集合
  fd_set *writefds, // 要监听写事件的 fd 集合
  fd_set *exceptfds, // 要监听异常事件的 fd 集合
  // 超时时间,null -> 永不超时,0 -> 不阻塞等待;大于 0 -> 固定时间等待
  struct timeval *timeout
)

select 模式存在的问题:

  • 需要将整个 fd_set 从用户空间拷贝到内核空间,select 结束还要再次拷贝会用户空间
  • select 无法得知具体是哪个 fd 就绪,需要遍历整个 fd_set
  • fd_set 监听的 fd 数量不能超过 1024

内存图:

poll

poll 模式对 select 模式做了简单改进,但性能提升不明显

c 复制代码
// pollfd 中的事件类型
#define POLLIN   // 可读事件
#define POLLOUT  // 可写事件
#define POLLERR  // 错误事件
#define POLLNVAL // fd 未打开

// pollfd 结构
struct pollfd {
  int fd;             // 要监听的 fd
  short int events;   // 要监听的事件类型:读、写、异常
  short int revents;  // 实际发生的事件类型
}

// poll 函数
struct poll {
  struct pollfd *fds, // pollfd 数组,可以自定义大小,没有上限
  nfds_t nfds,  // 数组元素个数
  int timeout // 超时时间
}

poll 函数相对 select 函数没有单独划分:读/写/异常事件,而是放在一个数组当中 pollfd

pollfd 结构体中,既包含了 fd 的值,也包含了事件的类型

我们在调用 poll 函数时,我们会创建多个 pollfd 结构体,只需要指定 fdevents,然后我们把 pollfd 传递给内核后,内核在监听时,如果发现事件有就绪,内核就会把就绪的事件类型放在 revents 中,如果等到超时时间过了,还没有发现任何就绪的事件,就会把 revents 设置为 0,代表没有发生任何事情,然后把 pollfd 返回给用户

所以整个 poll 的流程:

  1. 创建 pollfd 数组,向其中添加关注的 fd 信息,数组大小自定义
  2. 调用 poll 函数,将pollfd 数组拷贝到内核空间,转链表存储,无上限
  3. 内核遍历 fd ,判断是否就绪
  4. 数据就绪或超时后,拷贝 pollfd 数组到用户空间,返回就绪 fd 数量 n
  5. 用户进程判断 n 是否大于 0
  6. 大于 0 则遍历 pollfd 数组,找到就绪的 fd

select 对比:

  • select 模式中的 fd_set 大小固定为 1024,而 pollfd 在内核中采用链表,理论上无上限
  • 监听 fd 越多,每次遍历消耗时间也越久,性能反而会下降

内存图:

epoll

epoll 模式对 selectpoll 的改进,它提供了三个函数:

c 复制代码
struct eventpoll {
  // ...
  struct rb_root rbr; // 一颗红黑树,记录有监听的 fd
  struct list_head rdlist; // 一个链表,记录就绪的 fd
  // ...
}

// 1. 会在内核创建 eventpoll 结构体,返回对应的句柄 epfd(唯一的)
// 每一个 epfd 指向唯一的 eventpoll,每调一次 epoll_create 都会创建一个新的 eventpoll
int epoll_create(int size);
// 2. 将一个 fd 添加到 epoll 的红黑树中,并设置 ep_poll_callback
// callback 触发时,就把对应的 fd 加入到 rdlist 这个就绪列表中
int epoll_ctl(
  int epfd,  // epoll 实例的句柄
  int op,    // 要执行的操作,包括:ADD、MOD、DEL
  int fd,    // 要监听的 fd
  struct epoll_event *event   // 要监听的事件类型:读、写、异常等
);
// 3. 检查 rdlist 列表是否为空,不为空则返回就绪的 fd 数量
int epoll_wait(
  int epfd,   // eventpoll 实例句柄
  struct epoll_event *events, // 空 event 数组,用于存储就绪的 fd
  int maxevents,  // event 数组的最大长度
  int timeout // 超时时间,-1 不超时,0 不阻塞,>0 阻塞时间
)

epoll_ctl 的作用:添加 fdeventpoll,并做监听

epoll_wait 作用:等待 fd 就绪

内存图:

epoll 相比于 selectpoll 的优势:

  1. 拷贝的处理:
    • 减少拷贝 fd 的数量:selectpoll 是把内核中所有的 fd 都拷贝到用户中; epoll 只需要把要就绪的 fd 拷贝到内核中
    • 减少拷贝 fd 的次数:selectpoll 每次循环都需要把要监听的 fd 从用户拷贝到内核中,每一次还要把结果拷贝回用户空间;epoll 是把 select 的功能拆分开了:
      1. fd 拷贝到内核(epoll_ctl)
      2. 等待 fd 就绪(epoll_wait),循环处理只需要执行 epoll_wait,不需要再去执行 epoll_ctl 执行拷贝了
  2. selectpoll 拷贝到用户空间的是所有的 fd,需要遍历才知道哪个已经就绪,而 epoll 拷贝到用户空间的是已经就绪的 fd,不需要遍历
  3. poll 是假无限,用的是链表解决 fd 上限问题,理论上可以监听无数多的 fd,但随着 fd 的增多,遍历的性能会下降;epoll 是采用红黑树保存监听的 fd,理论上无上限,遍历性能不会下降

IO 多路复用事件通知机制

fd 有数据可读时,我们调用 epoll_wait 就可以得到通知,但是事件通知的模式有两种:

  1. LevelTriggered:简称 LT。当 fd 有数据可读时,会重复通知多次,直至数据处理完成。是 epoll 的默认模式
  2. EdgeTriggered:简称 ET。当 fd 有数据可读时,只会被通知一次,不管数据是否处理完成

例子:

  1. 假设一个客户端 socket 对应的 fd 已经注册到 epoll 实例中
  2. 客户端 socket 发送了 2kb 的数据
  3. 服务端 epoll_wait,得到通知说 fd 就绪
  4. 服务端从 fd 读取了 1kb 数据
  5. 回到步骤 3 (再次调用 epoll_wait 形成循环)

EF 模式数据一次读不完的解决方式有两种:

  1. 手动添加回去:
    1. EF 模式在 1 次数据读完之后,会直接将数据移除掉,下次在调用 epoll_wait 不会在得到通知
    2. 所以在第 1 次读完之后,需要手动的将数据添加回去,这里是调用 epoll_ctlepoll_ctl 作用就是对 epoll 实例中的 fd 做操作,包括:添加、修改、删除
    3. 当我们做修改时,eventpoll 会检查对应的 fd 是否就绪,如果会重新添加到 list_head 中,下次调用 epoll_wait 就会读到
    4. 这样重复的通知,类似于 LT 模式,对于性能影响比较大
  2. 在一次通知中,循环读取数据,直到读完全部数据
    • 使用这种方式不能使用阻塞 IO,如果使用阻塞 IO 当数据读完后不会返回,它会一直在那里等,导致整个进程阻塞
    • 所以如果需要再一次通知中读完所有数据,需要使用不阻塞 IO,一次循环读完,如果有数据会有一个标识,没有数据也会有一个标识,这样就可以在知道没数据后跳出循环

LT 模式存在两种问题:

  1. 重复通知对性能有影响
  2. 可能存在惊群现象,多个进程都监听到了已经就需要的 fd,都在尝试调用 epoll_wait,然后没有读完的数据还在 list_head 中,下一次又会是所有的进程调用 epoll_wait,这种就是惊群现象

多路复用服务流程图:

redis 网络模型

redis 通过 IO 多路复用来提高网络性能,并且支持不同的多路复用实现,并且将这些实现进行封装,提供了统一高性能事件处理接口

不同的系统,多路复用技术不同,redis 通过 ae.c 文件来封装不同的多路复用技术,ae.c 文件中包含了 ae_epoll.cae_evport.cae_kqueue.cae_select.c 四个文件,每个文件对应一种多路复用技术

  • ae_select.c 是一个通用的模块
  • ae_epoll.c -> Linux 平台设计的模块
  • ae_evport.c -> Solaris 平台设计的模块
  • ae_kqueue.c -> OS XFreeBSD 平台设计的模块

macox 用的是 ae_kqueue.cwindows 使用了一个跨平台的异步 I/Olibuv

c++ 复制代码
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
    #ifdef HAVE_EPOLL
    #include "ae_epoll.c"
    #else
        #ifdef HAVE_KQUEUE
        #include "ae_kqueue.c"
        #else
        #include "ae_select.c"
        #endif
    #endif
#endif

api 说明:

  • aeApiCreate(aeEventLoop *eventLoop) 创建多路复用程序,比如 epoll_create
  • aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) 注册 fd,比如 epoll_ctl
  • aeApiDelEvent(aeEventLoop *eventLoop, int fd, int mask) 删除 fd
  • aeApiPoll(aeEventLoop *eventLoop, int fd, int mask) 等待 fd 就绪,比如 epoll_waitselectpoll
c 复制代码
int main(int argc, char **argv) {
  // 初始化服务
  initServer();
  // 开始监听事件循环
  aeMain();
}
c 复制代码
void initServer(void) {
  // 内部会调用 aeApiCreate(eventLoop) 类似 epoll_create
  server.el = aeCreateEventLoop(server.maxclients + CONFIG_FDSET_INCR);
  // 监听 TCP 端口,创建 ServerSocket,并得到 FD
  // port 默认 6379,ip 默认 127.0.0.1
  listenToPort(server.port, &server.ipfd)
  // 注册 连接处理器,内部会调用 aeApiAddEvent(&server.ipfd) 监听 fd
  createSocketAcceptHandler(&server.ipfd, acceptTcpHandler)
  // 注册 ae_api_poll 前的处理器
  aeSetBeforeSleepProc(server.el, beforeSleep)
}
c 复制代码
void aeMain(aeEventLoop *eventLoop) {
  eventLoop->stop = 0;
  // 循环监听事件
  while(!eventLoop->stop){
    aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_BEFORE_SLEEP|AE_CALL_AFTER_SLEEP)
  }
}
c 复制代码
int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
  // 调用前置处理器,beforesleep
  eventLoop->beforesleep(eventLoop);
  // 等待 fd 就绪,类似 epoll_wait
  // numevents 是就绪 fd 的数量
  numevents = aeApiPoll(eventLoop, tvp);
  for(j = 0; j < numevents; j++){
    // 遍历处理就绪的 fd,调用对应的处理器
  }
}
c 复制代码
void acceptTcpHandler(...) {
  // 接收 socket 连接,获取 fd
  fd = accept(s, sa, len)
  // 创建 connection,关联 fd
  connection *conn = connCreateSocket();
  conn.fd = fd;
  // 内部调用 aeApiAddEvent(fd, READABLE)
  // 监听 socket 的 fd 读事件,并绑定读处理器 readQueryFromClient
  connSetReadHandler(conn, readQueryFromClient);
}
c 复制代码
void readQueryFromClient(...) {
  // 获取当前客户端,客户端中有缓冲区用来读和写
  client *c = connGetPrivateData(conn);
  // 获取 c->querybuf 缓冲区大小
  long int qblen = sdslen(c->querybuf);
  // 读取请求数据到 c->querybuf 缓冲区
  connRead(c->conn, c->querybuf+qblen, readlen);
  // 解析缓冲区字符串,转为 Redis 命令参数存入 c=>argv 数组
  processInputBuffer(c);
  // 处理 c->argv 中的命令
  processCommand(c);
}
int processCommand(client *c) {
  // 根据命令名称,寻找命令对应的 command,例如 setCommand
  c->cmd = c->lastcmd = lookupCommand(c->argv[0]->ptr);
  // 执行 command,得到响应结果,例如 ping 命令,对应 pingCommand
  c->cmd->proc(c);
  // 把执行结果写出,例如 ping 命令,就返回 "pong" 给 client
  // shared.pong 是字符串 "pong" 的 SDS 对象
  adReply(c, shared.pong);
}
void addReply(client *c, robj *obj) {
  // 尝试把结果写到 c->buf 客户端缓冲区
  if(_addReplyToBuffer(c, obj->ptr, sdslen(obj->ptr)) != C_OK);
    // 如果 c->buf 写不下,则写到 c->reply,这是一个链表,容量无限
    _addReplyObjectToList(c, obj->ptr, sdslen(obj->ptr));
  // 将客户端添加到 server.clients_pending_write 队列,等待被写出
  listAddNodeHead(server.clients_pending_write, c);
}
c 复制代码
void beforeSleep(struct aeEventLoop *eventLoop) {
  // 定义迭代器,指向 server.clients_pending_write->head 队列
  listIter li;
  li->next = server.clients_pending_write->head;
  li->direction = AL_START_HEAD;
  // 循环遍历写出 client
  while((ln = listNext(&li))){
    // 内部调用 aeApiAddEvent(fd, WRITABLE),监听 socket 的 fd 读事件
    // 并且绑定写处理器 sendReplyToClient,可以把响应写到客户端 socket
    connSetWriteHandlerWithBarrier(c->conn, sendReplyToClient, ae_barrier);
  }
}

redis 网络模型内存图:

通信协议

Redis 是一个 CS 架构的软件,通信一般分为两步:

  1. 客户端(client)向服务端(server)发送一条命令
  2. 服务端解析并执行命令,返回响应结果给客户端

因此客户端发送命令的格式、服务端响应结果的格式必须有一个规范,这个规范就是通信协议

Redis 采用的是 RESP 协议,全称是 Redis Serialization Protocol

RESP 协议 - 数据类型

RESP 中,通过首字母的字符来区分不同数据类型,常用的数据类型包括 5 种:

  1. 单行字符串:首字母是 +,后面跟上单行字符串,以 CRLF("\r\n") 结尾,例如返回 "OK""+OK\r\n"
  2. 错误(Errors):首字节是 -,与单行字符串格式一样,只是字符串是异常信息,例如:"-Error message\r\n"
  3. 数值:首字节是 :,后面跟上数字格式的字符串,以 CRLF 结尾,例如::10\r\n
  4. 多行字符串:首字节是 $,表示二进制安全的字符串,最大支持 512MB
    1. 如果大小为 0,则代表空字符串:"$0\r\n\r\n"
    2. 如果大小为 -1,则代表不存在: $-1\r\n
  5. 数组:首字节是 *,后面跟上数组元素个数,在跟上元素,元素数据类型不限

Redis 内存回收

通过 expire 命令给 reidskey 设置TTL(存活时间)

keyTTL 到期后,这个 key 会被释放,从而内存也会被释放掉

redis 是一个 key/value 内存存储数据库,因此所有的 keyvalue 都保存在 dict 结构中,在 database 结构体中,有两个 dict:一个用来记录 key/value,一个用来记录 key/TTL

c 复制代码
typedef struct redisDb {
  dict *dict; // 存放所有 key/value 的地方,也被称为 keyspace
  dict *expires; // 存放每一个 key 及其对应的 TLL 存活时间,只包含设置了 TTL 的 key
  // ...
}

过期策略

那么就会面临两个问题:

  1. redis 是如何知道 key 是否过期
    • 利用两个 dict 分别记录 key/valuekey/TTL
  2. 是不是 TTL 到期就立即删除呢
    • 惰性删除

      • 在访问一个 key 时,检查该 key 的存活时间,如果已经过期才执行删除
      c 复制代码
      // 查找一个 key 执行写操作
      robj *lookupKeyWriteWithFlags(redisDb *db, robj *key, int flags){
        // 检查 key 是否过期
        expireIfNeeded(db, key);
        return lookupKey(db, key, flags);
      }
      // 查找一个 key 执行读操作
      robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags){
        robj *val;
        // 检查 key 是否过期
        if(expireIfNeeded(db, key) == 1) {
           // ...
        }
        return lookupKey(db, key, flags);
      }
      c 复制代码
      int expireIfNeeded(redisDb *db, robj *key){
        // 判断是否过期,如果未过期直接结束并返回 0
        if(!keyIsExpired(db, key)) return 0;
        // ... 略
        // 删除过期的 key
        deleteExpiredKeyAndPropagate(db, key);
        return 1;
      }
    • 周期删除

      c 复制代码
      void aeMain(aeEventLoop *eventLoop){
        eventLoop->stop = 0;
        while(!eventLoop->stop){
          // beforeSleep() --> Fast 模式清理 // while 每次执行时,beforeSleep 调用间隔不低于 2ms --> 每次执行耗时很短,几十微秒到1毫秒
          // n = aeApiPoll()
          // 如果 n > 0,fd 就绪,处理 IO 事件
          // 如果到了执行时间,则调用 serverCron() --> Slow 模式清理 // 每次执行 serverCron 后都会返回一个时间,下一次执行到这一步时,会先检查一下这个时间,如果时间到了在执行这个函数,调用时间 100ms --> 每次执行耗时很长,几十毫秒
        }
      }
      • 周期性的抽样部分过期 key,然后执行删除,执行周期有两种:

        • redis 会设置一个定时任务 serverCron(),按照 server.hz 的频率来执行过期 key 的清理,模式为 SLOW
        c 复制代码
        // server.c
        void initServer(void) {
          // ...
          // 创建定时器,关联回调函数 serverCron,处理周期取决于 server.hz,默认 10
          aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL);
        }
        int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
          // 更新 lruclock 到当前时间,为后期的 LRU 和 LFU 做准备
          unsigned int lruclock = getLRUClock();
          atomicSet(server.lruclock, lruclock);
          // 执行 database 的数据清理,例如过期 key 处理
          databasesCron();
          return 1000/server.hz;
        }
        void databaseCron(void){
          // 尝试清理部分过期 key,清理模式为 SLOW
          activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
        }
        • redis 的每个事件循环前会调用 beforeSleep() 函数,执行过期 key 清理,模式为 FAST

          c 复制代码
          void beforeSleep(struct aeEventLoop *eventLoop) {
            // 尝试清理部分过期 key,清理模式为 FAST
            activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST);
          }
          • SLOW 模式规则:
            1. 执行频率受 server.hz 影像,默认为 10,即每秒执行 10 次,每个执行周期 100ms
            2. 执行清理耗时不超过一次执行周期的 25%,也就是说一个执行周期是 100ms,一次清理的时间不超过 25ms
            3. 逐个遍历 db,逐个遍历 db 中的 bucket,抽取 20key 判断是否过期
            4. 如果没达到时间上限(25ms),并且过期 key,比例大于 10%,再进行一次抽样,否则结束
          • FAST 模式规则(过期 key 比例小于 10% 不执行):
            1. 执行频率受 beforeSleep() 调用频率影响,但两次 FAST 模式间隔不低于 2ms
            2. 执行清理耗时不超过 1ms
            3. 逐个遍历 db,逐个遍历 db 中的 bucket,抽取 20key 判断是否过期
            4. 如果没达到时间上限(1ms),并且过期 key 比例大于 10%,再进行一次抽样,否则结束

淘汰策略

内存淘汰:就是当 redis 内存使用达到设置的阈值时,redis 主动挑选部分 key 删除以释放更多内存的流程。

redis 会在处理客户端命令的方法 processCommand() 中尝试做内存淘汰

c 复制代码
int processCommand(client *c){
  // 如果设置了 server.maxmemory 属性,并且并未有执行 lua 脚本
  if (server.maxmemory && !server.lua_timedout){
    // 尝试进行内存淘汰 performEvictions
    int out_of_memory = (performEvictions() == EVICT_FAIL);
    // ...
    if (out_of_memory && reject_cmd_on_oom) {
      rejectCommand(c, shared.oomerr);
      return C_OK;
    }
  }
}

redis 支持 8 种不同策略来选择要删除的 key

  1. noeviction:不淘汰任何 key,但是内存满时不允许写入新数据,默认就是这种
  2. volatile-ttl:对设置了 TTLkey,比较 key 的剩余 TTL 值,TTL 越小越先淘汰
  3. allkeys-random:对全体 key,随机进行淘汰,也就是直接从 db->dict 中随机挑选
  4. volatile-random:对设置了 TTLkey,随机进行淘汰,也就是从 db-expires 中随机挑选
  5. allkeys-lru:对全体 key,基于 LRU 算法进行淘汰,也就是直接从 db->dict 中挑选最少最近使用的 key
  6. volatile-lru:对设置了 TTLkey,基于 LRU 算法进行淘汰,也就是直接从 db->expires 中挑选最少最近使用的 key
  7. allkeys-lfu:对全体 key,基于 LFU 算法进行淘汰,也就是直接从 db->dict 中挑选最少频率使用的 key
  8. volatile-lfu:对设置了 TTLkey,基于 LFU 算法进行淘汰,也就是直接从 db->expires 中挑选最少频率使用的 key

其中 LRULFU 的区别是:

  • LRU 全称 Least Recently Used 最少最近使用,用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高
  • LFU 全称 Least Frequently Used 最少频率使用,会统计每个 key 访问的频率,值越小淘汰优先级越高

redis 的数据结构都会被封装为 RedisObject 结构:

c 复制代码
typedef struct redisObject {
  unsigned type:4; // 对象类型
  unsigned encoding:4; // 编码方式
  unsigned lru:LRU_BITS; // LRU:以秒为单位记录一次访问时间,长度为 24 bit
                         // LFU:高 16 位以分钟为单位记录最近一次访问时间,低 8 位记录逻辑访问次数
  int refcount; // 引用计数,计数为 0 则可以回收
  void *ptr; // 数据指针,指向真实数据
} robj;

LFU 的访问次数之所以叫做逻辑访问次数 ,是因为并不是每次 key 被访问都计数,而是通过运算:

  1. 生成 0 ~ 1 之间的随机数 R
  2. 计算 1 / (旧次数 * lfu_log_factor + 1),记录为 Plfu_log_factor 默认为 10
  3. 如果 R < P,则 P1,且最大不超过 255
  4. 访问次数会随时间衰减,距离上一次访问时间每隔 lfu_decay_time 分钟(默认 1),P1

淘汰策略流程图

更多文章

  1. Redis 基本命令
  2. Redis 源码:图解 Redis 六种数据结构
  3. Redis 源码:图解 Redis 五种数据类型
相关推荐
掐指一算乀缺钱17 分钟前
SpringBoot 数据库表结构文档生成
java·数据库·spring boot·后端·spring
一叶飘零_sweeeet32 分钟前
为什么 Feign 要用 HTTP 而不是 RPC?
java·网络协议·http·spring cloud·rpc·feign
KookeeyLena734 分钟前
动态IP与静态IP:哪种更适合用户使用?
网络·网络协议·tcp/ip
芊言芊语1 小时前
分布式缓存服务Redis版解析与配置方式
redis·分布式·缓存
攻城狮的梦2 小时前
redis集群模式连接
数据库·redis·缓存
时之彼岸Φ2 小时前
Web:HTTP包的相关操作
网络·网络协议·http
秋已杰爱2 小时前
HTTP中的Cookie与Session
服务器·网络协议·http
W21552 小时前
LINUX网络编程:http
网络·网络协议·http
计算机学姐2 小时前
基于python+django+vue的影视推荐系统
开发语言·vue.js·后端·python·mysql·django·intellij-idea
JustinNeil3 小时前
简化Java对象转换:高效实现大对象的Entity、VO、DTO互转与代码优化
后端