【Redis从入门到精通】第36篇:Redis客户端属性大揭秘——一个连接背后有多少状态
上一篇【第35篇】Redis为什么这么快——单线程也能跑出10万QPS的秘密
下一篇【第37篇】Redis服务器启动全流程——从redis-server到ready to accept
你敲下redis-cli,输入SET foo bar,回车,毫秒之间结果就回来了。这一切看起来轻松惬意,但在这背后,Redis服务器为你的连接维护了一个"小本本",里面密密麻麻记录着这个客户端的所有状态信息。这个"小本本"就是client结构体。今天,我们就来扒一扒一个Redis客户端连接到底有多少"秘密"。
client结构体——连接的"身份证"
在Redis源码中(server.h),client结构体是整个服务器与客户端交互的核心数据结构。每个redis-cli连接、每个主从复制链路、每个Sentinel监控连接,在服务器内部都是一个client实例。这个结构体有多大呢?粗略算一下,光字段数量就超过80个。我们挑重点来说。
先看看它的"五脏六腑":
client 结构体速览 ┌──────────────────────────────────┐ │ +-------------+ +------------+ │ │ | 网络层 | | 输入层 | │ │ | - fd | | - querybuf | │ │ | - ctime | | - argc/argv| │ │ | - flags | | - cmd | │ │ +-------------+ +------------+ │ │ +-------------+ +------------+ │ │ | 输出层 | | 状态层 | │ │ | - buf/bufpos| | - db | │ │ | - reply | | - name | │ │ | - sentlen | | - auth.. | │ │ +-------------+ +------------+ │ └──────────────────────────────────┘网络层核心字段
| 字段 | 类型 | 含义 | 值得注意的细节 |
|---|---|---|---|
fd | int | 文件描述符 | 普通客户端是socket fd;-1代表伪客户端(fake client) |
ctime | time_t | 客户端创建时间 | 用于计算连接存活时长,CLIENT LIST的age字段来源于此 |
lastinteraction | time_t | 最后交互时间 | 每次接收命令都会更新,超时检查的依据 |
flags | uint64_t | 客户端标志位 | 一个字段承载数十种状态,比如主从角色、阻塞状态、事务状态等 |
name | sds | 客户端名称 | 通过CLIENT SETNAME设置,调试时非常有用 |
flags标志位详解—— 这可能是整个结构体里信息密度最高的字段。它是一个64位的无符号整数,每个bit代表一种状态:
flags 常见标志位 Bit 63 Bit 0 ┌──────────────────────────────────────────────┐ │...│MONITOR│MASTER│SLAVE│BLOCKED│MULTI│PUBSUB│...│ └──────────────────────────────────────────────┘ 主要标志位说明: REDIS_SLAVE (1<<0) — 这是一个从库连接 REDIS_MASTER (1<<1) — 这是一个主库连接 REDIS_MONITOR (1<<2) — 客户端执行了 MONITOR 命令 REDIS_MULTI (1<<3) — 客户端处于事务(MULTI)状态 REDIS_BLOCKED (1<<4) — 客户端被阻塞(如BLPOP) REDIS_DIRTY_CAS (1<<5) — WATCH的key被修改过 REDIS_CLOSE_AFTER_REPLY (1<<6) — 回复后关闭连接 REDIS_UNIX_SOCKET (1<<7) — 通过Unix socket连接 REDIS_LUA_CLIENT (1<<8) — 执行Lua脚本的伪客户端 REDIS_ASKING (1<<9) — 集群模式下ASK转向 REDIS_READONLY (1<<10) — 集群从节点只读 REDIS_PUBSUB (1<<11) — 客户端订阅了频道 REDIS_PRE_PSYNC (1<<12) — 从库等待PSYNC回复踩坑提示:
CLIENT LIST中看到的flags字段(如N代表普通客户端、M代表master、S代表slave)就是由这些bit位映射而来的。如果你写了一个连接池,记得确认每个连接的状态标志是否正确——曾经有个生产事故就是因为某个连接被意外标记为MONITOR状态,导致服务器疯狂向它发送监控命令,最终OOM了。
输入缓冲区:命令的"缓冲区"
客户端发送过来的数据不是一次性就解析执行的,Redis设计了一套分层的输入缓冲区机制:
// client结构体中的输入相关字段structclient{sds querybuf;// 输入缓冲区,sds动态字符串size_tqb_pos;// querybuf已解析的位置intargc;// 当前命令的参数个数robj**argv;// 当前命令的参数列表structredisCommand*cmd;// 当前要执行的命令指针intreqtype;// 请求类型(内联命令还是RESP协议)};输入处理的ASCII流程图:
网络字节流 │ ▼ ┌──────────┐ │ querybuf │ ← 原始数据append到这里 └────┬─────┘ │ readQueryFromClient() ▼ ┌──────────────┐ │ RESP协议解析 │ ← processMultibulkBuffer() └──────┬───────┘ │ 解析成功 ▼ ┌──────────────┐ │ argc/argv │ ← 解析结果存入这里 └──────┬───────┘ │ lookupCommand() ▼ ┌──────────────┐ │ cmd │ ← 在redisCommandTable中找到 └──────┬───────┘ │ 参数校验 (arity) ▼ ┌──────────────┐ │ 执行命令 │ ← call() └──────────────┘关于querybuf有几个要点:
- 最大1GB:
querybuf使用sds动态字符串,理论上可以很大。但Redis有一个硬限制——proto-max-bulk-len(默认512MB),批量参数总长度不能超过这个值。 - 多次读取:
querybuf是累积的。如果一次read没读完整条命令,下次事件触发时会继续append,然后尝试重新解析。 qb_pos的作用:解析完一条命令后,qb_pos会标记已消费的位置。如果有剩余数据(管道模式),会从qb_pos之后继续解析下一个命令。
# 查看客户端输入缓冲区使用情况redis-cli CLIENT LIST|awk-F' ''{print $1, $NF}'# 输出示例:id=3 addr=127.0.0.1:54321 qbuf=0 qbuf-free=32768# qbuf=0 表示当前输入缓冲区没有未解析的数据(命令已被消费)# qbuf-free=32768 表示还有32KB空闲空间踩坑提示:如果
qbuf这个值持续增长,可能是客户端发了命令但Redis没解析——要么是协议格式错误,要么是命令不完整。曾经有人写了一个Java客户端,忘记在命令末尾加\r\n,导致Redis一直等数据,qbuf慢慢涨到几百MB,最终触发OOM杀手。
输出缓冲区:回复的"双通道"
Redis的回复发送也很有意思,它用了"固定缓冲区 + 链表"双通道设计:
// 输出相关字段structclient{charbuf[PROTO_REPLY_CHUNK_BYTES];// 固定大小输出缓冲区(默认16KB)intbufpos;// buf中已使用的字节数list*reply;// 输出缓冲区链表(用于大回复)size_treply_bytes;// reply链表中总字节数size_tsentlen;// 当前对象已发送的字节数};输出机制示意图:
addReply() 被调用 │ ▼ ┌─────────────────────────────┐ │ 总回复量 < 16KB? │ └──┬──────────────┬──────────┘ YES NO │ │ ▼ ▼ ┌───────┐ ┌──────────────┐ │ buf[] │ │ buf[]写满后 │ │ │ │ 追加到reply链表│ └───┬───┘ └──────┬───────┘ │ │ └───────┬───────┘ ▼ ┌──────────────────────┐ │ 可写事件触发时 │ │ writeToClient() 发送 │ └──────────────────────┘这个设计的精妙之处在于:
- 小回复(如
+OK\r\n、:1\r\n)直接放进buf[16KB],零额外分配。 - 大回复(如
KEYS *返回几万条key)会创建reply链表节点,每个节点是一个clientReplyBlock。 sentlen字段用于跟踪当前正在发送的链表节点已经发了多少字节,避免每次从头开始。
# 查看输出缓冲区状态redis-cli CLIENT LIST|awk-F' ''{print $1, $(NF-2), $(NF-1)}'# 输出示例:id=3 obl=0 oll=0 omem=0# obl: 固定缓冲区已用字节数 (output buffer length)# oll: 输出链表中的对象数 (output list length)# omem: 输出链表占用的总内存 (output memory)踩坑提示:如果
omem这个值持续增长,说明有客户端订阅了大量频道或者执行了返回海量数据的命令(如没加LIMIT的KEYS *),Redis正在拼命往回复链表里塞数据。配置client-output-buffer-limit就是为了防这种情况:
# redis.conf 中的配置# 普通客户端的输出缓冲区限制client-output-buffer-limit normal000# 从库客户端的限制(更严格)# 格式:hard-limit soft-limit soft-secondsclient-output-buffer-limit replica 256mb 64mb60# 订阅客户端的限制client-output-buffer-limit pubsub 32mb 8mb60配置含义:replica 256mb 64mb 60表示——硬限制256MB(超过立刻断开),软限制64MB(超过并持续60秒才断开)。这个"软硬兼施"的设计避免了偶发的突发流量导致断开,又能防止长期堆积。
客户端的"生老病死"
出生:createClient()
当一个TCP连接到达Redis服务器时,整个流程是这样的:
acceptTcpHandler (网络事件回调) │ ▼ anetTcpAccept() — accept()系统调用 │ ▼ acceptCommonHandler() │ ├── 检查 maxclients 限制 │ 超限 → 发送错误并关闭连接 │ ▼ createClient(conn) │ ├── calloc client结构体 ├── 设置默认属性(db=0, flags=0, authenticated=0) ├── 设置fd为非阻塞 ├── 关闭Nagle算法(TCP_NODELAY) ├── 设置KeepAlive ├── 创建读文件事件(绑定readQueryFromClient回调) ├── 更新connected_clients计数器 └── 添加到server.clients链表部分核心代码:
client*createClient(connection*conn){client*c=zmalloc(sizeof(client));// 设置文件描述符if(conn){// 非阻塞 + TCP_NODELAYconnNonBlock(conn);connEnableTcpNoDelay(conn);// KeepAliveif(server.tcpkeepalive)connKeepAlive(conn,server.tcpkeepalive);// 注册读事件connSetReadHandler(conn,readQueryFromClient);}// 默认选择0号数据库selectDb(c,0);uint64_tclient_id=++server.next_client_id;c->id=client_id;// sds初始化(每次分配16KB)c->querybuf=sdsempty();// 链接到全局客户端链表listAddNodeTail(server.clients,c);returnc;}一生:状态流转
客户端的主要状态变迁:
┌─────────┐ │ 新连接 │ └────┬────┘ │ createClient ▼ ┌─────────┐ ┌───→│ 普通状态 │ ← 执行命令、接收回复 │ └────┬────┘ │ │ MULTI BLPOP SUBSCRIBE │ ▼ ▼ ▼ │ ┌────────┐ ┌──────────┐ ┌────────┐ │ │ 事务中 │ │ 阻塞等待 │ │ 订阅中 │ │ └───┬────┘ └────┬─────┘ └───┬────┘ │ │ EXEC/DISCARD│ 超时/数据就绪 │ UNSUBSCRIBE │ └─────────────┴─────────────┘ │ │ └──────────────────────┘ │ ▼ ┌─────────┐ │ 关闭连接 │ └─────────┘死亡:freeClient()的触发条件
Redis客户端有"九种死法"(不开玩笑):
| 序号 | 死因 | 触发条件 | 相关参数 |
|---|---|---|---|
| 1 | 客户端主动关闭 | 客户端发送QUIT命令或TCP断开 | — |
| 2 | 空闲超时 | lastinteraction超过timeout秒 | timeout(默认0=不超时) |
| 3 | 输入缓冲区超限 | querybuf超过硬限制 | client-query-buffer-limit(默认1GB) |
| 4 | 输出缓冲区硬限制 | reply_bytes超过hard-limit | client-output-buffer-limit |
| 5 | 输出缓冲区软限制 | 超过soft-limit且持续soft-seconds | 同上 |
| 6 | maxclients超限 | 连接数超过maxclients | maxclients(默认10000) |
| 7 | CLIENT KILL | 管理员主动KILL | — |
| 8 | CLIENT PAUSE期间的新命令 | 暂停期间非允许的命令 | — |
| 9 | 服务器关闭 | SHUTDOWN或进程退出 | — |
# 最常用的客户端超时配置# redis.conftimeout300# 5分钟不活跃就断开# 设置为0表示永不超时(默认)# 查看和在线修改redis-cli CONFIG GETtimeoutredis-cli CONFIG SETtimeout600CLIENT命令家族——管理连接的神器
CLIENT LIST:一窥连接全景
CLIENT LIST是运维人员用得最多的命令之一。它的输出一行为一个客户端:
id=3 addr=127.0.0.1:52341 laddr=127.0.0.1:6379 fd=8 name=myapp age=845 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 argv-mem=10 obl=0 oll=0 omem=0 tot-mem=38808 events=r cmd=ping user=default redir=-1 resp=2字段含义对照表:
| 字段 | 含义 | 关注场景 |
|---|---|---|
id | 客户端唯一ID(递增分配) | 精确定位某个连接 |
addr | 客户端IP:端口 | 排查恶意连接来源 |
laddr | 服务器本地监听地址 | 多网卡时区分入口 |
fd | 文件描述符编号 | -1表示伪客户端 |
name | 客户端名称 | CLIENT SETNAME设置 |
age | 连接已存活秒数 | 发现长连接或频繁重连 |
idle | 空闲秒数 | 发现僵尸连接 |
flags | 客户端标志 | N(普通)/M(master)/S(slave)/O(MONITOR)/b(blocked)等 |
db | 当前操作的数据库编号 | 确认连接使用哪个DB |
sub/psub | 订阅的频道/模式数量 | 排查pubsub连接 |
multi | 事务队列中命令数 | -1表示不在事务中 |
qbuf | 输入缓冲区未消费字节数 | 正常应该接近0 |
qbuf-free | 输入缓冲区剩余空间 | sds预分配了空间 |
obl/oll/omem | 输出缓冲区状态 | omem过大需警惕 |
cmd | 当前/最后执行的命令 | 定位慢查询 |
resp | RESP协议版本 | 2或3 |
CLIENT KILL:精准剪断连接
# 按地址杀CLIENT KILL addr192.168.1.100:52341# 按ID杀CLIENT KILLid3# 按类型批量杀CLIENT KILLtypenormal# 只杀普通客户端CLIENT KILLtypepubsub# 只杀pubsub客户端CLIENT KILLtypemaster# 杀主库连接(慎用!)CLIENT KILLtypeslave# 杀从库连接# 组合条件使用(Redis 2.8.12+)CLIENT KILL addr10.0.0.0/24typenormalCLIENT PAUSE:服务器"暂停营业"
当需要升级Redis版本或切换主从时,让服务器暂停处理客户端命令:
# 暂停所有客户端命令,10000毫秒(10秒)CLIENT PAUSE10000# 仅暂停写命令(Redis 6.2+)CLIENT PAUSE10000WRITE# 查看是否处于暂停状态redis-cli INFO clients|greppaused# paused_clients:1暂停期间,主从复制、心跳等内部通信不受影响。CLIENT UNPAUSE可以提前解除暂停。
CLIENT NO-EVICT:VIP客户免驱
如果你有"尊贵"的客户端不能被淘汰:
# 将这个连接标记为NO-EVICTCLIENT NO-EVICT ON# 即使超过maxmemory,这个客户端的连接也不会被淘汰注意:NO-EVICT是Redis 7.0才引入的特性。
伪客户端——不需要socket的特殊客户端
Redis中有两类特殊的客户端,它们没有网络连接,fd = -1:
伪客户端(Fake Client)家族 ┌───────────────────────┐ │ server.aof_client │ ← 载入AOF文件时使用 │ (AOF载入伪客户端) │ ├───────────────────────┤ │ server.lua_client │ ← 执行Lua脚本时使用 │ (Lua脚本伪客户端) │ ├───────────────────────┤ │ server.master/client │ ← 主从复制中的主/从端 │ (复制伪客户端变体) │ └───────────────────────┘AOF伪客户端:载入AOF文件恢复数据时,Redis创建一个伪客户端,把AOF文件中的每条命令"塞"给这个客户端执行。这样就避免了为恢复数据而新建网络连接。
Lua伪客户端:执行EVAL命令时,Lua脚本中的redis.call()实际上是通过一个伪客户端来执行命令的。这种设计让脚本中的命令可以和普通命令走同一套执行路径。
踩坑提示:Lua脚本执行期间,Lua伪客户端会持有
server.lua_client,这个伪客户端会绕过很多安全检查(比如不会触发CLIENT PAUSE的暂停逻辑)。这就是为什么长时间运行的Lua脚本会阻塞整个服务器——它在执行的是"伪客户端"的命令,但阻塞的是整个事件循环。
连接数监控——别让服务器撑死
INFO clients输出
redis-cli INFO clients# 输出示例:# connected_clients:152 ← 当前连接数# cluster_connections:0 ← 集群内部连接数# maxclients:10000 ← 最大连接数限制# client_recent_max_input_buffer:1024 ← 近期最大输入缓冲区(字节)# client_recent_max_output_buffer:51200 ← 近期最大输出缓冲区(字节)# blocked_clients:3 ← 被阻塞的客户端数# tracking_clients:0 ← 客户端跟踪数(Redis 6.0+)# clients_in_timeout_table:0 ← 等待超时的客户端数# total_connections_received:58432 ← 历史总连接数(含已关闭)连接数监控脚本
#!/bin/bash# 监控Redis连接数,超过80%告警HOST="127.0.0.1"PORT="6379"connected=$(redis-cli-h$HOST-p$PORT INFO clients|grep"connected_clients"|cut-d:-f2)maxclients=$(redis-cli-h$HOST-p$PORT INFO clients|grep"maxclients"|cut-d:-f2)ratio=$(echo"scale=2;$connected/$maxclients* 100"|bc)echo"当前连接数:$connected/$maxclients($ratio%)"if(($(echo "$ratio>80"|bc-l)));thenecho"WARNING: 连接数超过80%!"redis-cli-h$HOST-p$PORTCLIENT LIST|\awk-F'[ =]''{print $3}'|sort|uniq-c|sort-rn|head-10fi不同场景的连接数经验值
| 场景 | 典型连接数 | 调优建议 |
|---|---|---|
| 小规模应用 | < 100 | 保持默认maxclients=10000即可 |
| 中型API服务 | 100-500 | 关注idle时间,设置timeout |
| 大型微服务 | 500-2000 | 考虑使用连接池,设置合理maxclients |
| 长连接Pubsub | 1000-10000 | 监控omem,配置client-output-buffer-limit |
| 超大集群 | 10000+ | 开启cluster模式,分散到多个节点 |
踩坑提示:一个常见误区是"连接数多=性能好"。实际上,大量空闲连接只会浪费文件描述符和内存。Redis的单线程模型决定了命令执行是串行的,500个连接同时发命令和5个连接发命令,实际的QPS差异并不大。真正需要关注的是"活跃连接数",而不是总连接数。
总结与最佳实践
让我们用一张图回顾今天的内容:
Redis 客户端全景 ┌─────────────────────────────────────────────────────────┐ │ │ │ 创建 运行中 关闭 │ │ ┌──────┐ ┌─────────────────────┐ ┌──────┐ │ │ │accept│───→│ • 输入缓冲(querybuf) │───→│超时 │ │ │ │ │ │ • 输出缓冲(buf/reply) │ │OOM │ │ │ │create│ │ • 命令执行(argc/argv) │ │QUIT │ │ │ │Client│ │ • 状态管理(flags) │ │KILL │ │ │ └──────┘ └─────────────────────┘ └──────┘ │ │ │ │ 管理工具:CLIENT LIST / KILL / PAUSE / SETNAME / INFO │ │ │ │ 特殊客户端:AOF/Lua伪客户端(fd=-1) │ │ │ └─────────────────────────────────────────────────────────┘几个关键要点:
client结构体是Redis连接管理的核心,囊括了网络、输入、输出、状态四个维度。- 输入缓冲区
querybuf使用sds动态扩容,正常情况下qbuf应为0(命令已被消费)。 - 输出缓冲区采用"固定16KB + 链表"双通道,小回复走buf,大回复走reply链表。
omem持续增长是大问题。 CLIENT LIST是排查连接问题的第一板斧,学会解读每个字段。- 伪客户端(fd=-1)用于AOF载入和Lua脚本执行,它们绕过网络但复用命令执行路径。
- 合理配置
timeout、maxclients、client-output-buffer-limit是运维的基本功。
下一篇文章我们将见证Redis服务器从redis-server命令启动到"ready to accept connections"的完整旅程——六个阶段,每个阶段都有很多你没注意到的细节。
上一篇【第35篇】Redis为什么这么快——单线程也能跑出10万QPS的秘密
下一篇【第37篇】Redis服务器启动全流程——从redis-server到ready to accept
