Redis 事件机制详解

Stella981
• 阅读 799

点击上方"程序员历小冰",选择“置顶或者星标”

你的关注意义重大!

Redis 采用事件驱动机制来处理大量的网络IO。它并没有使用 libevent 或者 libev 这样的成熟开源方案,而是自己实现一个非常简洁的事件驱动库 ae_event。

Redis中的事件驱动库只关注网络IO,以及定时器。该事件库处理下面两类事件:

  • 文件事件(file  event):用于处理 Redis 服务器和客户端之间的网络IO。

  • 时间事件(time  eveat):Redis 服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间事件就是处理这类定时操作的。

事件驱动库的代码主要是在src/ae.c中实现的,其示意图如下所示。

Redis 事件机制详解

aeEventLoop是整个事件驱动的核心,它管理着文件事件表和时间事件列表,不断地循环处理着就绪的文件事件和到期的时间事件。下面我们就先分别介绍文件事件和时间事件,然后讲述相关的 aeEventLoop源码实现。

文件事件

Redis基于Reactor模式开发了自己的网络事件处理器,也就是文件事件处理器。文件事件处理器使用IO多路复用技术,同时监听多个套接字,并为套接字关联不同的事件处理函数。当套接字的可读或者可写事件触发时,就会调用相应的事件处理函数。

Redis 使用的IO多路复用技术主要有:selectepollevportkqueue等。每个IO多路复用函数库在 Redis 源码中都对应一个单独的文件,比如aeselect.c,aeepoll.c, ae_kqueue.c等。Redis 会根据不同的操作系统,按照不同的优先级选择多路复用技术。事件响应框架一般都采用该架构,比如 netty 和 libevent。

Redis 事件机制详解

如下图所示,文件事件处理器有四个组成部分,它们分别是套接字、I/O多路复用程序、文件事件分派器以及事件处理器。

Redis 事件机制详解

文件事件是对套接字操作的抽象,每当一个套接字准备好执行 accept、read、write和 close 等操作时,就会产生一个文件事件。因为 Redis 通常会连接多个套接字,所以多个文件事件有可能并发的出现。

I/O多路复用程序负责监听多个套接字,并向文件事件派发器传递那些产生了事件的套接字。

尽管多个文件事件可能会并发地出现,但I/O多路复用程序总是会将所有产生的套接字都放到同一个队列(也就是后文中描述的 aeEventLoopfired就绪事件表)里边,然后文件事件处理器会以有序、同步、单个套接字的方式处理该队列中的套接字,也就是处理就绪的文件事件。

Redis 事件机制详解

所以,一次 Redis 客户端与服务器进行连接并且发送命令的过程如上图所示。

  • 客户端向服务端发起建立 socket 连接的请求,那么监听套接字将产生 AEREADABLE 事件,触发连接应答处理器执行。处理器会对客户端的连接请求进行应答,然后创建客户端套接字,以及客户端状态,并将客户端套接字的 AEREADABLE 事件与命令请求处理器关联。

  • 客户端建立连接后,向服务器发送命令,那么客户端套接字将产生 AE_READABLE 事件,触发命令请求处理器执行,处理器读取客户端命令,然后传递给相关程序去执行。

  • 执行命令获得相应的命令回复,为了将命令回复传递给客户端,服务器将客户端套接字的 AEWRITEABLE 事件与命令回复处理器关联。当客户端试图读取命令回复时,客户端套接字产生 AEWRITEABLE 事件,触发命令回复处理器将命令回复全部写入到套接字中。

时间事件

Redis 的时间事件分为以下两类:

  • 定时事件:让一段程序在指定的时间之后执行一次。

  • 周期性事件:让一段程序每隔指定时间就执行一次。

Redis 的时间事件的具体定义结构如下所示。

  1. typedef struct aeTimeEvent {

  2. /* 全局唯一ID */

  3. long long id; /* time event identifier. */

  4. /* 秒精确的UNIX时间戳,记录时间事件到达的时间*/

  5. long when_sec; /* seconds */

  6. /* 毫秒精确的UNIX时间戳,记录时间事件到达的时间*/

  7. long when_ms; /* milliseconds */

  8. /* 时间处理器 */

  9. aeTimeProc *timeProc;

  10. /* 事件结束回调函数,析构一些资源*/

  11. aeEventFinalizerProc *finalizerProc;

  12. /* 私有数据 */

  13. void *clientData;

  14. /* 前驱节点 */

  15. struct aeTimeEvent *prev;

  16. /* 后继节点 */

  17. struct aeTimeEvent *next;

  18. } aeTimeEvent;

一个时间事件是定时事件还是周期性事件取决于时间处理器的返回值:

  • 如果返回值是 AE_NOMORE,那么这个事件是一个定时事件,该事件在达到后删除,之后不会再重复。

  • 如果返回值是非 AE_NOMORE 的值,那么这个事件为周期性事件,当一个时间事件到达后,服务器会根据时间处理器的返回值,对时间事件的 when 属性进行更新,让这个事件在一段时间后再次达到。

Redis 将所有时间事件都放在一个无序链表中,每次 Redis 会遍历整个链表,查找所有已经到达的时间事件,并且调用相应的事件处理器。

介绍完文件事件和时间事件,我们接下来看一下 aeEventLoop的具体实现。

创建事件管理器

Redis 服务端在其初始化函数 initServer中,会创建事件管理器 aeEventLoop对象。

函数 aeCreateEventLoop将创建一个事件管理器,主要是初始化 aeEventLoop的各个属性值,比如 eventsfiredtimeEventHeadapidata

  • 首先创建 aeEventLoop对象。

  • 初始化未就绪文件事件表、就绪文件事件表。 events指针指向未就绪文件事件表、 fired指针指向就绪文件事件表。表的内容在后面添加具体事件时进行初变更。

  • 初始化时间事件列表,设置 timeEventHead和 timeEventNextId属性。

  • 调用 aeApiCreate 函数创建 epoll实例,并初始化 apidata

  1. aeEventLoop *aeCreateEventLoop(int setsize) {

  2. aeEventLoop *eventLoop;

  3. int i;

  4. /* 创建事件状态结构 */

  5. if ((eventLoop = zmalloc(sizeof(*eventLoop))) == NULL) goto err;

  6. /* 创建未就绪事件表、就绪事件表 */

  7. eventLoop->events = zmalloc(sizeof(aeFileEvent)*setsize);

  8. eventLoop->fired = zmalloc(sizeof(aeFiredEvent)*setsize);

  9. if (eventLoop->events == NULL || eventLoop->fired == NULL) goto err;

  10. /* 设置数组大小 */

  11. eventLoop->setsize = setsize;

  12. /* 初始化执行最近一次执行时间 */

  13. eventLoop->lastTime = time(NULL);

  14. /* 初始化时间事件结构 */

  15. eventLoop->timeEventHead = NULL;

  16. eventLoop->timeEventNextId = 0;

  17. eventLoop->stop = 0;

  18. eventLoop->maxfd = -1;

  19. eventLoop->beforesleep = NULL;

  20. eventLoop->aftersleep = NULL;

  21. /* 将多路复用io与事件管理器关联起来 */

  22. if (aeApiCreate(eventLoop) == -1) goto err;

  23. /* 初始化监听事件 */

  24. for (i = 0; i < setsize; i++)

  25. eventLoop->events[i].mask = AE_NONE;

  26. return eventLoop;

  27. err:

  28. .....

  29. }

aeApiCreate 函数首先创建了 aeApiState对象,初始化了epoll就绪事件表;然后调用 epoll_create创建了 epoll实例,最后将该 aeApiState赋值给 apidata属性。

aeApiState对象中 epfd存储 epoll的标识, events是一个 epoll就绪事件数组,当有 epoll事件发生时,所有发生的 epoll事件和其描述符将存储在这个数组中。这个就绪事件数组由应用层开辟空间、内核负责把所有发生的事件填充到该数组。

  1. static int aeApiCreate(aeEventLoop *eventLoop) {

  2. aeApiState *state = zmalloc(sizeof(aeApiState));

  3. if (!state) return -1;

  4. /* 初始化epoll就绪事件表 */

  5. state->events = zmalloc(sizeof(struct epoll_event)*eventLoop->setsize);

  6. if (!state->events) {

  7. zfree(state);

  8. return -1;

  9. }

  10. /* 创建 epoll 实例 */

  11. state->epfd = epoll_create(1024); /* 1024 is just a hint for the kernel */

  12. if (state->epfd == -1) {

  13. zfree(state->events);

  14. zfree(state);

  15. return -1;

  16. }

  17. /* 事件管理器与epoll关联 */

  18. eventLoop->apidata = state;

  19. return 0;

  20. }

  21. typedef struct aeApiState {

  22. /* epoll_event 实例描述符*/

  23. int epfd;

  24. /* 存储epoll就绪事件表 */

  25. struct epoll_event *events;

  26. } aeApiState;

创建文件事件

aeFileEvent是文件事件结构,对于每一个具体的事件,都有读处理函数和写处理函数等。Redis 调用 aeCreateFileEvent函数针对不同的套接字的读写事件注册对应的文件事件。

  1. typedef struct aeFileEvent {

  2. /* 监听事件类型掩码,值可以是 AE_READABLE 或 AE_WRITABLE */

  3. int mask;

  4. /* 读事件处理器 */

  5. aeFileProc *rfileProc;

  6. /* 写事件处理器 */

  7. aeFileProc *wfileProc;

  8. /* 多路复用库的私有数据 */

  9. void *clientData;

  10. } aeFileEvent;

  11. /* 使用typedef定义的处理器函数的函数类型 */

  12. typedef void aeFileProc(struct aeEventLoop *eventLoop,

  13. int fd, void *clientData, int mask);

比如说,Redis 进行主从复制时,从服务器需要主服务器建立连接,它会发起一个 socekt连接,然后调用 aeCreateFileEvent函数针对发起的socket的读写事件注册了对应的事件处理器,也就是 syncWithMaster函数。

  1. aeCreateFileEvent(server.el,fd,AE_READABLE|AE_WRITABLE,syncWithMaster,NULL);

  2. /* 符合aeFileProc的函数定义 */

  3. void syncWithMaster(aeEventLoop *el, int fd, void *privdata, int mask) {....}

aeCreateFileEvent的参数 fd指的是具体的 socket套接字, procfd产生事件时,具体的处理函数, clientData则是回调处理函数时需要传入的数据。aeCreateFileEvent主要做了三件事情:

  • 以 fd为索引,在 events未就绪事件表中找到对应事件。

  • 调用 aeApiAddEvent函数,该事件注册到具体的底层 I/O 多路复用中,本例为epoll。

  • 填充事件的回调、参数、事件类型等参数。

  1. int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,

  2. aeFileProc *proc, void *clientData)

  3. {

  4. /* 取出 fd 对应的文件事件结构, fd 代表具体的 socket 套接字 */

  5. aeFileEvent *fe = &eventLoop->events[fd];

  6. /* 监听指定 fd 的指定事件 */

  7. if (aeApiAddEvent(eventLoop, fd, mask) == -1)

  8. return AE_ERR;

  9. /* 置文件事件类型,以及事件的处理器 */

  10. fe->mask |= mask;

  11. if (mask & AE_READABLE) fe->rfileProc = proc;

  12. if (mask & AE_WRITABLE) fe->wfileProc = proc;

  13. /* 私有数据 */

  14. fe->clientData = clientData;

  15. if (fd > eventLoop->maxfd)

  16. eventLoop->maxfd = fd;

  17. return AE_OK;

  18. }

如上文所说,Redis 基于的底层 I/O 多路复用库有多套,所以 aeApiAddEvent也有多套实现,下面的源码是 epoll下的实现。其核心操作就是调用 epollepoll_ctl函数来向 epoll注册响应事件。有关 epoll相关的知识可以看一下《Java NIO源码分析》

  1. static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {

  2. aeApiState *state = eventLoop->apidata;

  3. struct epoll_event ee = {0}; /* avoid valgrind warning */

  4. /* 如果 fd 没有关联任何事件,那么这是一个 ADD 操作。如果已经关联了某个/某些事件,那么这是一个 MOD 操作。*/

  5. int op = eventLoop->events[fd].mask == AE_NONE ?

  6. EPOLL_CTL_ADD : EPOLL_CTL_MOD;

  7. /* 注册事件到 epoll */

  8. ee.events = 0;

  9. mask |= eventLoop->events[fd].mask; /* Merge old events */

  10. if (mask & AE_READABLE) ee.events |= EPOLLIN;

  11. if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;

  12. ee.data.fd = fd;

  13. /* 调用epoll_ctl 系统调用,将事件加入epoll中 */

  14. if (epoll_ctl(state->epfd,op,fd,&ee) == -1) return -1;

  15. return 0;

  16. }

事件处理

因为 Redis 中同时存在文件事件和时间事件两个事件类型,所以服务器必须对这两个事件进行调度,决定何时处理文件事件,何时处理时间事件,以及如何调度它们。

aeMain函数以一个无限循环不断地调用 aeProcessEvents函数来处理所有的事件。

  1. void aeMain(aeEventLoop *eventLoop) {

  2. eventLoop->stop = 0;

  3. while (!eventLoop->stop) {

  4. /* 如果有需要在事件处理前执行的函数,那么执行它 */

  5. if (eventLoop->beforesleep != NULL)

  6. eventLoop->beforesleep(eventLoop);

  7. /* 开始处理事件*/

  8. aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);

  9. }

  10. }

下面是 aeProcessEvents的伪代码,它会首先计算距离当前时间最近的时间事件,以此计算一个超时时间;然后调用 aeApiPoll函数去等待底层的I/O多路复用事件就绪;aeApiPoll函数返回之后,会处理所有已经产生文件事件和已经达到的时间事件。

  1. /* 伪代码 */

  2. int aeProcessEvents(aeEventLoop *eventLoop, int flags) {

  3. /* 获取到达时间距离当前时间最接近的时间事件*/

  4. time_event = aeSearchNearestTimer();

  5. /* 计算最接近的时间事件距离到达还有多少毫秒*/

  6. remaind_ms = time_event.when - unix_ts_now();

  7. /* 如果事件已经到达,那么remaind_ms为负数,将其设置为0 */

  8. if (remaind_ms < 0) remaind_ms = 0;

  9. /* 根据 remaind_ms 的值,创建 timeval 结构*/

  10. timeval = create_timeval_with_ms(remaind_ms);

  11. /* 阻塞并等待文件事件产生,最大阻塞时间由传入的 timeval 结构决定,如果remaind_ms 的值为0,则aeApiPoll 调用后立刻返回,不阻塞*/

  12. /* aeApiPoll调用epoll_wait函数,等待I/O事件*/

  13. aeApiPoll(timeval);

  14. /* 处理所有已经产生的文件事件*/

  15. processFileEvents();

  16. /* 处理所有已经到达的时间事件*/

  17. processTimeEvents();

  18. }

aeApiAddEvent类似, aeApiPoll也有多套实现,它其实就做了两件事情,调用 epoll_wait阻塞等待 epoll的事件就绪,超时时间就是之前根据最快达到时间事件计算而来的超时时间;然后将就绪的 epoll事件转换到fired就绪事件。aeApiPoll就是上文所说的I/O多路复用程序。具体过程如下图所示。

Redis 事件机制详解

  1. static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp)

  2. {

  3. aeApiState *state = eventLoop->apidata;

  4. int retval, numevents = 0;

  5. // 调用epoll_wait函数,等待时间为最近达到时间事件的时间计算而来。

  6. retval = epoll_wait(state->epfd,state->events,eventLoop->setsize,

  7. tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1);

  8. // 有至少一个事件就绪?

  9. if (retval > 0)

  10. {

  11. int j;

  12. /*为已就绪事件设置相应的模式,并加入到 eventLoop 的 fired 数组中*/

  13. numevents = retval;

  14. for (j = 0; j < numevents; j++)

  15. {

  16. int mask = 0;

  17. struct epoll_event *e = state->events+j;

  18. if (e->events & EPOLLIN)

  19. mask |= AE_READABLE;

  20. if (e->events & EPOLLOUT)

  21. mask |= AE_WRITABLE;

  22. if (e->events & EPOLLERR)

  23. mask |= AE_WRITABLE;

  24. if (e->events & EPOLLHUP)

  25. mask |= AE_WRITABLE;

  26. /* 设置就绪事件表元素 */

  27. eventLoop->fired[j].fd = e->data.fd;

  28. eventLoop->fired[j].mask = mask;

  29. }

  30. }

  31. // 返回已就绪事件个数

  32. return numevents;

  33. }

processFileEvent是处理就绪文件事件的伪代码,也是上文所述的文件事件分派器,它其实就是遍历 fired就绪事件表,然后根据对应的事件类型来调用事件中注册的不同处理器,读事件调用 rfileProc,而写事件调用 wfileProc

  1. void processFileEvent(int numevents) {

  2. for (j = 0; j < numevents; j++) {

  3. /* 从已就绪数组中获取事件 */

  4. aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];

  5. int mask = eventLoop->fired[j].mask;

  6. int fd = eventLoop->fired[j].fd;

  7. int fired = 0;

  8. int invert = fe->mask & AE_BARRIER;

  9. /* 读事件 */

  10. if (!invert && fe->mask & mask & AE_READABLE) {

  11. /* 调用读处理函数 */

  12. fe->rfileProc(eventLoop,fd,fe->clientData,mask);

  13. fired++;

  14. }

  15. /* 写事件. */

  16. if (fe->mask & mask & AE_WRITABLE) {

  17. if (!fired || fe->wfileProc != fe->rfileProc) {

  18. fe->wfileProc(eventLoop,fd,fe->clientData,mask);

  19. fired++;

  20. }

  21. }

  22. if (invert && fe->mask & mask & AE_READABLE) {

  23. if (!fired || fe->wfileProc != fe->rfileProc) {

  24. fe->rfileProc(eventLoop,fd,fe->clientData,mask);

  25. fired++;

  26. }

  27. }

  28. processed++;

  29. }

  30. }

  31. }

processTimeEvents是处理时间事件的函数,它会遍历 aeEventLoop的事件事件列表,如果时间事件到达就执行其 timeProc函数,并根据函数的返回值是否等于 AE_NOMORE来决定该时间事件是否是周期性事件,并修改器到达时间。

  1. static int processTimeEvents(aeEventLoop *eventLoop) {

  2. int processed = 0;

  3. aeTimeEvent *te;

  4. long long maxId;

  5. time_t now = time(NULL);

  6. ....

  7. eventLoop->lastTime = now;

  8. te = eventLoop->timeEventHead;

  9. maxId = eventLoop->timeEventNextId-1;

  10. /* 遍历时间事件链表 */

  11. while(te) {

  12. long now_sec, now_ms;

  13. long long id;

  14. /* 删除需要删除的时间事件 */

  15. if (te->id == AE_DELETED_EVENT_ID) {

  16. aeTimeEvent *next = te->next;

  17. if (te->prev)

  18. te->prev->next = te->next;

  19. else

  20. eventLoop->timeEventHead = te->next;

  21. if (te->next)

  22. te->next->prev = te->prev;

  23. if (te->finalizerProc)

  24. te->finalizerProc(eventLoop, te->clientData);

  25. zfree(te);

  26. te = next;

  27. continue;

  28. }

  29. /* id 大于最大maxId,是该循环周期生成的时间事件,不处理 */

  30. if (te->id > maxId) {

  31. te = te->next;

  32. continue;

  33. }

  34. aeGetTime(&now_sec, &now_ms);

  35. /* 事件已经到达,调用其timeProc函数*/

  36. if (now_sec > te->when_sec ||

  37. (now_sec == te->when_sec && now_ms >= te->when_ms))

  38. {

  39. int retval;

  40. id = te->id;

  41. retval = te->timeProc(eventLoop, id, te->clientData);

  42. processed++;

  43. /* 如果返回值不等于 AE_NOMORE,表示是一个周期性事件,修改其when_sec和when_ms属性*/

  44. if (retval != AE_NOMORE) {

  45. aeAddMillisecondsToNow(retval,&te->when_sec,&te->when_ms);

  46. } else {

  47. /* 一次性事件,标记为需删除,下次遍历时会删除*/

  48. te->id = AE_DELETED_EVENT_ID;

  49. }

  50. }

  51. te = te->next;

  52. }

  53. return processed;

  54. }

删除事件

当不在需要某个事件时,需要把事件删除掉。例如: 如果fd同时监听读事件、写事件。当不在需要监听写事件时,可以把该fd的写事件删除。

aeDeleteEventLoop函数的执行过程总结为以下几个步骤 1、根据 fd在未就绪表中查找到事件 2、取消该 fd对应的相应事件标识符 3、调用 aeApiFree函数,内核会将epoll监听红黑树上的相应事件监听取消。

后记

接下来,我们会继续学习 Redis 的主从复制相关的原理,欢迎大家持续关注。

-关注我

Redis 事件机制详解

推荐阅读

十二张图带你了解 Redis 的数据结构和对象系统

Redis RDB 持久化详解

基于Redis和Lua的分布式限流

本文分享自微信公众号 - 程序员历小冰(gh_a1d0b50d8f0a)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
待兔 待兔
3个月前
手写Java HashMap源码
HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22
Jacquelyn38 Jacquelyn38
3年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Wesley13 Wesley13
3年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
3年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Stella981 Stella981
3年前
Docker 部署SpringBoot项目不香吗?
  公众号改版后文章乱序推荐,希望你可以点击上方“Java进阶架构师”,点击右上角,将我们设为★“星标”!这样才不会错过每日进阶架构文章呀。  !(http://dingyue.ws.126.net/2020/0920/b00fbfc7j00qgy5xy002kd200qo00hsg00it00cj.jpg)  2
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
为什么mysql不推荐使用雪花ID作为主键
作者:毛辰飞背景在mysql中设计表的时候,mysql官方推荐不要使用uuid或者不连续不重复的雪花id(long形且唯一),而是推荐连续自增的主键id,官方的推荐是auto_increment,那么为什么不建议采用uuid,使用uuid究
Python进阶者 Python进阶者
9个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这