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 五种数据类型
相关推荐
爱勇宝3 小时前
第 1 章:别把“需求文档”当成真正的需求
前端·后端·程序员
IT_陈寒8 小时前
闭包陷阱让我加了两天班,JavaScript你真行
前端·人工智能·后端
易协同低代码9 小时前
通达OA核心类库TD类深度解析
后端
Gopher_HBo9 小时前
Go语言学习笔记(十八)Gin处理Session
后端
谭光志10 小时前
工具塞满上下文窗口怎么办?深度拆解 AI Agent Tool Search 按需加载实现原理
前端·后端·ai编程
她说..10 小时前
Java 默认值设置方式
java·开发语言·后端·springboot
foggyprojects10 小时前
从0开始,一句话启动AI DataAgent
后端·数据分析·ai编程
郡杰10 小时前
一些基础和问题解决
后端
陈随易10 小时前
前端项目部署只要30秒
前端·后端·程序员
YIAN10 小时前
从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理
前端·后端·mcp