Redis 客户端运行链:命令、连接与超时结果
Redis 客户端把应用中的键和值编码为命令,通过连接交给服务端,再把回复转换为 Java 值。一次 GET 可能返回字符串、未找到结果或错误;一次 INCR 没有收到回复,则还要判断服务端是否已经修改了计数。
理解这条链路,需要同时看命令语义、连接状态和调用者的等待时间。只在 catch 中统一重试,会让读取失败与重复写入混在一起。
命令、数据类型与连接
从方法调用到 RESP 字节
Java 调用
→ 客户端编码:命令名、key、参数
→ 连接队列与网络写出
→ Redis 解析、权限检查、查找 key、执行命令
→ 服务端回复
→ 客户端读取、解码、完成 Future
→ 调用者取得值或异常RESP 使用明确的类型标记和长度描述数据。下面是 GET key 的 RESP2 请求表示,\r\n 表示两个协议换行字节:
*2\r\n
$3\r\n
GET\r\n
$3\r\n
key\r\n*2 表示两个数组元素,$3 表示后续元素是三个字节的 bulk string。长度以字节计算,不是 Java 字符数;中文、二进制内容需要按实际编码后的长度处理。日常开发应交给客户端编码,避免拼接文本造成协议长度错误。
回复也有类型:简单字符串、整数、bulk string、数组和错误等。不存在的键与空字符串是不同结果。RESP3 增加 map、set、push 等表示,客户端可能在建立连接时通过 HELLO 协商版本;不要假定所有连接都按 RESP2 文本解释。完整字节格式见 RESP 规范。
连接返回了 WRONGTYPE,说明服务端已理解命令,但目标 key 的类型不适用。连接建立失败、认证失败和命令执行错误,应该分别记录。
选择数据类型时先看操作
| 类型或能力 | 典型用途 | 常用操作与代价 |
|---|---|---|
| String | 文本、序列化对象、计数 | GET / SET;INCR 修改整数值 |
| Hash | 同一对象的字段 | HGET / HSET;全量 HGETALL 成本随字段增加 |
| List | 有顺序的元素 | 两端入队出队;阻塞读取需要独立考虑连接 |
| Set | 去重成员 | 成员查询、交并差;全量读取受集合大小影响 |
| Sorted Set | 排名、分数或时间排序 | 分数与成员共同确定排序,范围读取要限量 |
| Stream | 有标识的追加记录 | 消费组、确认与保留期;不是普通 GET 缓存 |
| Bitmap / HyperLogLog | 位状态、近似去重计数 | 适合明确的编码或误差要求 |
| Bloom、JSON 等扩展能力 | 存在性过滤或文档操作 | 核对服务器发行版及命令支持 |
Redis 8 的发行包整合了更多数据能力,但应用仍应根据目标服务器确认可用命令。完整类型入口见 Redis data types;实验使用固定的 Redis 8.10.1,版本变更以 官方发行记录为准。
把整个对象作为 String 中的 JSON 保存时,更新单个字段通常需要重新写入整个值。Hash 可以分别读写字段,但字段之间需要同时变化时仍要选择原子命令、事务或脚本。数据类型应随访问方式选择,而不是为了“更结构化”就一律换成 Hash。
key、TTL 与类型是服务端状态
key 应包含足够的应用、业务对象和格式标识,避免不同写入者使用同名键却采用不同类型。影响查询结果的租户、语言等条件也需要进入键。不要把密码、完整令牌、手机号等敏感内容直接作为可被运维扫描的键名。
SET 带 EX 或 PX 可以在写入值时同时设置过期。先 SET、再单独 EXPIRE 会多一个失败窗口:第二步没有执行时,条目可能一直保留。对同一键再次普通 SET,原来的 TTL 默认被清除;需要保留时明确使用 KEEPTTL,不能依赖旧 TTL 自动延续。SET 命令定义了这些条件和选项。
TTL key / PTTL key
正数或 0:剩余秒数 / 毫秒数
-1:键存在,但没有过期时间
-2:键不存在一次 GET 没有结果,可能来自从未写入、到期、显式删除、驱逐或切换后缺失。TTL 和 INFO 统计提供不同线索,但不能仅凭一个 nil 反推出删除原因。时间判断与返回约定见 TTL。
同步接口也可以复用异步连接
Lettuce 的同步、异步和响应式 API 共享客户端基础设施。同步调用等待命令结果;异步调用返回 RedisFuture;响应式调用通过发布者交付结果。应用不必为每个普通 GET 创建一个新 TCP 连接。
普通无阻塞命令可以通过线程安全连接复用。阻塞命令、MULTI/WATCH 等带连接状态的流程则要使用专用连接,避免一个线程改变整个连接的行为,让其他线程也进入事务或等待。连接池的价值常在这些需要独占会话状态的操作,而不只是把所有 GET 都分配到池中。Lettuce 连接说明列出了独立实例、Sentinel 和 Cluster 的连接方式。
处理完成回调时,也要知道它运行在哪个线程。若在 Netty event loop 中执行耗时计算、阻塞 JDBC 或同步等待同一连接的新结果,后续回复的读取就可能被拖住。异步返回形式不会自动把这些工作移到适合阻塞的线程池。
在隔离网络中连接真实 Redis
准备源码和镜像
环境为 Linux Bash、Docker、unzip,操作者为具有 Docker 权限的普通用户。下载 缓存实验工程,按 Caffeine 准备步骤解包,并先执行 LocalCacheTest 的 clean verify,取得 PROJECT_DIR、LAB_DIR 和已下载的 Maven 依赖。
这一步先在普通构建网络下载依赖;后面的集成实验使用 internal 网络,不能依靠它访问外部 Maven 仓库。若镜像尚未取得,先下载:
docker pull redis:8.10.1
docker pull maven:3.9.12-eclipse-temurin-25
docker pull eclipse-temurin:25.0.4_7-jdk镜像源不可达时,使用组织批准的 registry 镜像同步或离线导入方式,核对导入后的实际版本。不要因为网络访问失败就关闭 TLS 校验。
工程内的 redis.conf 为一次性实验配置:
bind 0.0.0.0
port 6379
protected-mode no
save ""
appendonly no
maxmemory 64mb
maxmemory-policy noeviction
dir /tmp这个配置仅用于没有宿主端口发布的隔离 Docker 网络。未配置认证和持久化,退出后数据可丢,不可直接用于生产。生产连接需要受限 ACL 用户、TLS、网络访问控制和与业务状态相符的持久化方案。
test "$(id -u)" -ne 0 || exit 1
docker network create --internal ca11-lab
docker run -d --name ca11-redis --network ca11-lab \
--user 999:999 --read-only --tmpfs /tmp:rw,mode=1777,size=64m \
-v "$PROJECT_DIR/redis.conf:/usr/local/etc/redis/redis.conf:ro" \
redis:8.10.1 redis-server /usr/local/etc/redis/redis.conf
docker logs --tail 30 ca11-redis
docker exec ca11-redis redis-cli PING预期 PONG。Redis 进程使用镜像中 redis 用户对应的 UID/GID 999;没有 -p,主机和外部客户端不能通过本实验发布的端口访问它。具有 Docker 控制权限的人仍可加入网络或 exec,因此 internal 网络不替代身份授权。
若同名容器或网络已经存在,先确认是否来自自己仍在运行的缓存实验;需要继续使用时检查配置,不直接删除不明资源。
用 redis-cli 读取值和 TTL
docker exec ca11-redis redis-cli SET ca11:client:title:v1 title-v1 EX 30
docker exec ca11-redis redis-cli GET ca11:client:title:v1
docker exec ca11-redis redis-cli TTL ca11:client:title:v1
docker exec ca11-redis redis-cli HSET ca11:client:product:v1 price 19.90 currency CNY
docker exec ca11-redis redis-cli HGET ca11:client:product:v1 price
docker exec ca11-redis redis-cli HLEN ca11:client:product:v1依次能看到 SET 的 OK、title-v1、剩余 TTL,以及 price=19.90、字段数 2。TTL 受实际执行时间影响,不应要求它每次恰好为 30;如果已经到期,重新执行 SET 后继续。
String 与 Hash 的类型差异可以直接验证:
if docker exec ca11-redis redis-cli -e \
LPUSH ca11:client:title:v1 item; then
echo '预期 WRONGTYPE,但命令成功'
exit 1
fi应在 String 尚未过期时执行,得到 WRONGTYPE。redis-cli 的 -e 让命令错误反映为非零退出;若先前 String 已到期,LPUSH 会创建新的 List,因此这时成功是前置状态变化,不是类型约束失效。需要稳定重试时,先重新 SET title-v1 EX 30。
Java 连接、调用与关闭
docker run --rm --network ca11-lab \
--user 10001:10001 --read-only --tmpfs /tmp:rw,mode=1777,size=64m \
-e REDIS_URI=redis://ca11-redis:6379 \
-v "$PROJECT_DIR/target/cache-runtime-lab-1.0.0.jar:/app/app.jar:ro" \
eclipse-temurin:25.0.4_7-jdk java -jar /app/app.jar redis输出 ping=PONG、set=OK、get=title-v1 和 0–30 范围内的 ttlSeconds。入口为 RedisAccess.demo,使用 Boot 4.1.1 管理的 Lettuce 7.5.2.RELEASE。每次使用随机实验键,在 finally 中删除自己的键并关闭连接及客户端。
RedisURI uri = RedisURI.create("redis://ca11-redis:6379");
uri.setTimeout(Duration.ofSeconds(2));
RedisClient client = RedisClient.create(uri);
client.setOptions(ClientOptions.builder()
.requestQueueSize(128)
.autoReconnect(false)
.build());这里明确禁用自动重连,便于实验中把一次连接丢失与后续操作分开;不是建议所有生产应用都禁用重连。客户端连接需要关闭,RedisClient 持有的事件循环等资源也需要 shutdown;连接创建失败时同样要关闭已经创建的 client。
实验入口只接受 ca11-redis:6379,防止测试被误指向现有生产实例。配置选项作用于之后创建的连接,修改 client 的选项不会自动改写已有连接。Client Options介绍了队列上限、断开行为与重连配置。
批量、事务和脚本各解决什么
Pipeline 减少逐次等待
普通同步循环是发一条、等一条,再发下一条。Pipeline 允许先提交多条独立命令,随后分别取得回复,减少串行网络往返和系统调用开销。它不要求所有命令放进同一个 TCP 包。
顺序等待:SET → reply → GET → reply → INCR → reply
Pipeline:SET → GET → INCR → 分别取得三个结果批量大小应同时考虑命令数与回复字节数。三百个短计数和三百个大 JSON,客户端堆、服务端输出缓冲及网络占用相差很大。Redis Pipelining说明了收益来源和服务端回复缓冲成本。
Lettuce 的异步调用本身就可以形成流水处理。手动关闭 auto-flush 属于连接级控制,适合独占连接的批量流程;不要在一个被许多业务线程共享的连接上关闭刷新,否则其他线程的命令也可能留在发送队列中等待。
实验使用独立连接:
connection.setAutoFlushCommands(false);
RedisFuture<String> first = connection.async().set(textKey, "text");
RedisFuture<Long> wrong = connection.async().lpush(textKey, "item");
RedisFuture<Long> last = connection.async().incr(counterKey);
connection.flushCommands();得到的三个结果分别是 OK、WRONGTYPE、1。随后从另一条连接读取,String 仍为 text,计数仍为 1。中间命令错误没有撤销前后写入。
取得所有结果后关闭这条独占连接。实际业务若要复用它,应先恢复自动刷新,并确保没有遗留未完成命令;每个 Future 的成功或失败都需要处理,不能只等待最后一个结果。
MULTI/EXEC 连续执行,但运行时错误不会回滚
MULTI 让同一连接之后的命令排队;EXEC 开始执行队列。其他客户端的命令不会穿插在这组执行中,但这套事务没有关系数据库式的失败回滚。
MULTI
SET textKey text
LPUSH textKey item
INCR counterKey
EXEC
执行结果:[OK, WRONGTYPE, 1]
保存状态:textKey=text,counterKey=1实验将上述流程交给真实 Redis,检查 TransactionResult 和最终键值。需要区分两类错误:命令排队时就发现的语法或参数错误,可能让 EXEC 放弃事务;开始执行后才发生的类型错误,则在结果数组中占据对应位置,其余命令仍可能执行。完整语义见 Redis Transactions。
WATCH 提供乐观检查:先监视 key,在 EXEC 前该 key 被修改,就放弃排队的事务。实验第二条连接先把计数 0 增到 1,第一条连接随后尝试写 100;EXEC 被丢弃,最终值保持 1。
这时可以在有界重试次数和总时间内重新读取、计算、WATCH,而不是重用先前基于旧值算出的结果。高竞争热点反复失败时,应考虑单条原子命令或短脚本,避免把冲突变成无限重试。
Lua 把短小的读改写放在服务端
脚本可以先读取、比较,再决定写入,执行期间不会被普通命令插入修改。所有访问的 key 应通过 KEYS 显式传入,其他输入使用 ARGV。这样既方便参数化,也让 Cluster 能确定涉及哪些键。
原子执行描述的是执行期间的隔离,不是失败自动回滚。RedisProtocolTest 先在 Lua 中 SET written,再对同一个 String 执行 LPUSH,脚本报 WRONGTYPE;独立 GET 仍取得 written。因此需要尽量在第一次写入前完成可预见的类型和参数检查。
长脚本会延长其他请求等待。需要扫描全库或调用外部服务的业务,不适合搬进 Lua。脚本管理、KEYS/ARGV 和错误传播见 Scripting with Lua。
EVALSHA 只发送脚本摘要,依赖目标节点还保存对应脚本。收到 NOSCRIPT 后可以重新加载或使用已知脚本执行;节点重启、切换和脚本缓存管理都会影响可用性。不能把脚本摘要当成持久化业务版本,脚本源码与参数协议仍需随应用管理。
超时、拓扑和故障处理
没收到回复时,先保留“结果未知”
调用者看到:超时
可能的位置:
连接尚未建立
命令仍在客户端队列
命令已写出,服务端尚未执行
服务端已执行,回复尚未到达
回复已到客户端,解码或回调尚未完成这些位置的后续动作不同。连接前就失败的调用可能尚未发出业务命令;写出后的 INCR 则可能已经改变值。一个异常名通常不足以恢复完整网络时序。
实验用专用原始连接发送 CLIENT REPLY OFF,再发送 INCR。Redis 按要求不回复,socket 读取在 200 ms 后超时;另一条正常连接仍读到计数 1。再执行一次 INCR,计数成为 2。
这里主动关闭的是服务端回复,不是假定真的发生了网络丢包。它稳定验证了“调用者缺少回复”与“服务端已经执行”可以同时出现。CLIENT REPLY定义了 OFF、ON 和 SKIP 的行为。该连接只用于实验,关闭后不再复用。
读取通常可以重新执行,但可能取得更新后的值;计数、扣减、发号等写入需要根据业务操作键和最终状态决定是否重试。即使 SET 相同值看起来可重复,附带 TTL 的重发也可能重新计算过期时间。
Lettuce 默认重连策略还可能重发断开时排队的命令。是否允许重放、哪些命令可重放、断线时是否继续接收新命令,必须与业务重试一起配置。实验的 autoReconnect(false) 用于隔离这种自动行为;生产策略见 Lettuce Production usage。
给等待和排队分别设置限制
| 时间或资源 | 应限制什么 | 常见错误 |
|---|---|---|
| 连接建立 | DNS、TCP、TLS 与连接激活耗时 | 只设置业务方法超时 |
| 命令等待 | 一条调用等待结果的时间 | 每次重试重新获得完整预算 |
| 连接池等待 | 取得独占连接的排队时间 | 池已经耗尽,却继续无限等待 |
| 在途命令 | 每连接 pending 数量与请求字节 | 无限 Future 把压力搬进堆 |
| 批量回复 | 一批命令的总响应大小 | 只限制命令条数 |
| 整个业务请求 | 包括缓存、回源和重试的总 deadline | 每一层都认为自己还有完整时间 |
requestQueueSize 的单位是排队命令数,仍应估算每条命令和回复的大小。Cluster 会拥有多条节点连接,不能把一个连接的队列上限当成整个应用的总上限。
超时太短会放大误失败与重试,太长则让线程或在途命令持续占用。先观察正常分布及故障时队列,再配置有界重试和退避;非幂等操作不要因连接恢复就自动重复。
Sentinel、Cluster 与读副本
独立 Redis 连接指向一个地址。Sentinel 帮助客户端发现主节点和切换;Cluster 按 16384 个哈希槽分布 key,客户端需要维护槽到节点的路由。两种模式不能只靠把连接字符串多写几个主机名来替代相应客户端支持。
Cluster 返回 MOVED 时提示槽归属变化;ASK 表示迁移过程中的临时重定向,需要按协议处理本次请求。目标节点返回的地址也必须从应用网络可达,入口种子节点能连接,不代表后续节点都能连接。
多键命令、事务和脚本常要求键位于同一槽。可以使用业务范围合适的 hash tag,例如 order:{42}:data 与 order:{42}:lock;把所有键都放进同一个 tag 又会使分片失去作用。槽分布、重定向和故障切换说明见 Redis Cluster。
副本复制有延迟,刚写主节点后立即读副本可能仍是旧值。资金确认、会话撤销或更新后立即展示等任务,需要明确读哪里、如何处理版本落后。WAIT 可以降低部分复制丢失风险,但不把 Redis 自动变成强一致数据库。缓存作为可重新生成的数据,与锁、会话和幂等结果的可靠性要求应分开判断。
本实验只启动单节点,未覆盖 Sentinel、Cluster 或故障切换。更换生产拓扑时,应补充真实节点停止、地址变化、旧连接和在途写入的对照。
认证、TLS 与可执行的权限反例
生产应用使用独立 ACL 用户,限制可执行命令、允许的 key pattern 和必要 Pub/Sub channel。不要把应用账号直接赋予全部管理命令,也不要仅依赖键名前缀表达隔离。
实验为一次测试创建随机用户,只允许本次随机前缀下的 GET/SET 及连接握手所需命令。它可以读写自己的 key;读取前缀外的键得到 NOPERM,执行 CONFIG GET 也被拒绝。结束后删除该临时用户,不把随机密码写进输出。
Redis ACL定义了命令、键和频道授权。新客户端可能在连接时执行 HELLO、CLIENT SETINFO 等握手命令,限制权限时应根据实际调用明确放行,不能因一次握手失败就改成 +@all。
认证并不加密链路。使用 TLS 时,应校验证书链及服务器身份,凭据通过受控配置或密钥系统提供,不放入公开日志或命令截图。Redis TLS给出了证书和端口配置入口。
用测试与诊断命令核对结果
docker run --rm --network ca11-lab --user "$(id -u):$(id -g)" \
-e REDIS_URI=redis://ca11-redis:6379 \
-e MAVEN_CONFIG=/tmp/maven \
-v "$PROJECT_DIR:/work" -v "$LAB_DIR/m2:/cache" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/cache \
-o -Dtest=RedisProtocolTest test预期 7 项测试通过:类型/TTL、Pipeline、MULTI、WATCH、Lua 失败、缺失回复和 ACL。故意触发的错误由测试断言,Maven 仍应成功退出。缺少 Redis 地址、连接不到实验节点或出现非预期结果时,测试会失败,不会自动跳过。
排查实际应用时,先保存客户端失败类型、耗时、重试次数和命令类别,再检查服务器:
docker exec ca11-redis redis-cli INFO clients
docker exec ca11-redis redis-cli INFO commandstats
docker exec ca11-redis redis-cli CLIENT LIST
docker exec ca11-redis redis-cli SLOWLOG GET 10CLIENT LIST 的 qbuf、omem 等字段帮助观察输入与输出缓冲;客户端读取慢或大批量回复可能在这里积压。SLOWLOG 主要记录服务端命令执行耗时,不包含与客户端通信的全部时间,因此应用超时而 SLOWLOG 没有记录也很常见。SLOWLOG GET说明了计时范围。
COMMAND INFO 返回命令参数、标志和键位置等元数据,不会直接给出一份命令复杂度说明文本。判断 HGETALL 等操作的复杂度,应查看对应命令文档,再结合集合元素数与输出大小。返回结构见 COMMAND INFO。
| 观察 | 优先判断 | 下一步 |
|---|---|---|
| PING 也连不上 | 网络、进程、端口或连接建立 | 先确认容器与客户端是否在同一可达网络 |
| 只有登录失败 | ACL 用户、凭据、握手权限、TLS | 核对失败的具体命令,不扩大到全部权限 |
| WRONGTYPE | 同名 key 被其他代码写成不同结构 | 找到写入者,迁移键格式再重试 |
| 客户端超时但服务端命令很短 | 队列、连接池、event loop、网络或大回复 | 分开量测端到端与服务端耗时 |
| Pipeline 部分失败 | 每项 Future 与最终键状态 | 只处理失败项及结果未知项 |
| 切换后旧值或重复修改 | 副本延迟、重放策略与业务重试 | 查询最终状态,核对操作键 |
| 客户端内存增长 | pending 数量、批大小、读取速度 | 减小并发批次,限制队列与响应 |
实验结束后,只清理本次命名的容器与网络:
docker stop ca11-redis
docker rm ca11-redis
docker network rm ca11-lab停止实例会丢弃本次 Redis 临时数据。若继续进行后续缓存实验,可以暂时保留它;工程与 Maven 缓存仍在 LAB_DIR。
