Jedis操作Redis实战指南:从基础命令到生产级调优
1. 为什么Java项目里我一直在用Jedis操作RedisJedis这个词只要你做过后端开发大概率在依赖清单里见过。它是Java生态里最早被广泛使用的Redis客户端说白了就是一个让我们在Java代码里操作Redis的中间层库。Redis本身是C语言写的服务端我们Java程序没法直接跟它对话Jedis就是那根“电话线”把Java方法调用翻译成RESP协议指令发给Redis再把结果翻译回Java对象。我在多个项目里都用它处理缓存、分布式锁、排行榜这类场景。它的好处用一句话概括接口直观跟Redis命令几乎一一对应。你熟悉Redis的命令行就基本会写Jedis代码比如SET对应setHGETALL对应hgetAll没有那么多抽象包装上手成本非常低。如果你是刚接触Redis的Java工程师或者项目里需要快速接入缓存功能这篇文章就是给你准备的。当然也有人说Jedis线程不安全说它过时了但实际项目中它依然有大量应用场景。所谓线程不安全是指同一个Jedis实例不能在多线程里共享解决方式也很简单——用连接池每个线程从池子里拿一个独立的连接实例用完归还。这个套路我后面会详细讲。Jedis适合那些想要直接控制连接、代码简单直白的项目尤其是中小规模系统和内部工具它比某些重型客户端更省心。接下来我按从零开始建项目到生产实践的路径把Jedis操作Redis的完整链路拆给你看。2. 环境准备与第一个Jedis程序2.1 引入依赖别在版本上翻车操作Jedis第一步是在项目里加入依赖。我用的是Maven配置如下dependency groupIdredis.clients/groupId artifactIdjedis/artifactId version4.4.3/version /dependency版本建议选4.x以上。原因有三个第一4.x支持Redis 6.x、7.x新增的命令类型比如SET带NX和EX参数的原子操作在封装上更顺手第二4.x开始改进了连接池内部实现在高并发下更稳定第三旧版3.x在某些场景下存在资源回收不彻底的问题容易把连接数打满。如果你用的是Spring Boot还可以通过spring-boot-starter-data-redis间接引入但那样默认走的是Lettuce。想强制用Jedis需要排除Lettuce后单独引入Jedis依赖。我这里讲的是脱离Spring直接使用Jedis原生API的方式这样你对底层机制的理解会更深以后切换框架也不慌。2.2 建立连接从最简单的单机模式开始写代码之前先确保本机或服务器上Redis已经启动并且端口是默认的6379。连接方式非常简单import redis.clients.jedis.Jedis; public class QuickStart { public static void main(String[] args) { // 指定Redis服务地址和端口 Jedis jedis new Jedis(127.0.0.1, 6379); // 如果Redis设置了密码 jedis.auth(yourpassword); // 测试连通性 System.out.println(Ping result: jedis.ping()); // 写一个字符串 jedis.set(blog:title, 通过Jedis操作Redis); // 读出来 String title jedis.get(blog:title); System.out.println(Read from Redis: title); jedis.close(); } }这段代码是入门标配。new Jedis(host, port)直接创建一个TCP连接ping()用来确认服务是否存活set和get就是最基础的读写操作。这里有个细节jedis.close()不是可有可无的Jedis的close()方法会释放底层Socket资源。在真实项目里我们通常不直接new连接而是从连接池获取这一点在第4章节具体展开。2.3 理解Jedis对象模型命令即方法Jedis的设计哲学特别朴实——Redis有什么命令它就有什么方法。比如Redis里SETEX命令表示“设置值并指定过期时间”到Jedis里就是setex(key, seconds, value)。这种一一映射关系让学习曲线几乎平坦。用个生活类比你把Redis当成一个储物柜Jedis就是你的钥匙串和手写标签。每个标签对应一个操作指令想存东西就贴标签放进去想取东西就按标签拿。没有中间商赚差价所以速度快、心智负担小。Jedis支持的数据类型操作包括字符串String缓存、计数器、分布式锁的基础哈希Hash存储对象字段读写单个字段列表List消息队列简易版、最新消息列表集合Set去重、标签系统、随机抽奖有序集合ZSet排行榜、延时队列后面我逐一展示代码示例每个示例都是我在真实业务中验证过的写法。3. 五大数据类型操作实战3.1 字符串类型缓存与计数器的核心字符串是Redis最基础也最常用的类型在Jedis里对应的方法名也最直白。// 基本读写 jedis.set(user:10001:name, 张三); String name jedis.get(user:10001:name); // 带过期时间的写入单位是秒 jedis.setex(session:token:abc123, 3600, login_user_10001); // 只有当key不存在时才设置成功常用于分布式锁 Long result jedis.setnx(lock:order:20001, 1);这里重点说两个容易忽略的点。第一setex是原子操作相当于set加expire两步同时完成。我在项目里遇到过同事先set再expire的写法如果程序在这两步之间崩溃key就会变成永不过期的“僵尸数据”白白占用内存。所以带过期时间的写入一定要用setex。第二setnx是“set if not exists”的缩写它是实现分布式锁的基石。第二个参数的值可以是任意字符串但如果你要区分不同线程的锁标识通常会把线程唯一ID放进去释放锁时校验这个值防止误删别人的锁。字符串类型还经常用来做计数器比如统计每日访问量Long visitCount jedis.incr(stats:visit:20240218); // incrBy可以指定增加幅度 jedis.incrBy(stats:score:10001, 10);incr是原子自增在并发场景下不用担心数值错乱。做秒杀库存扣减也可以用decr操作配合setnx的返回值判断是否扣减成功。3.2 哈希类型对象数据的最佳容器项目里我们经常需要存用户信息、商品信息这样的结构化数据。单独拼字符串能存但读一个字段要把整个字符串拿回来解析很浪费。哈希类型就是为这种场景设计的。// 设置单个字段 jedis.hset(user:10001, name, 张三); jedis.hset(user:10001, age, 28); jedis.hset(user:10001, city, 杭州); // 一次性设置多个字段 MapString, String userInfo new HashMap(); userInfo.put(name, 李四); userInfo.put(age, 32); userInfo.put(city, 深圳); jedis.hset(user:10002, userInfo); // 读取全部字段 MapString, String allInfo jedis.hgetAll(user:10001); // 读取单个字段 String city jedis.hget(user:10001, city);哈希类型有个直观的好处——修改某个字段不需要动整个对象。想象一下用户在个人中心改了头像底层只用执行一次hset(user:10001, avatar, new_url)而如果用字符串存储整个对象就得先get再反序列化再修改再序列化再set性能差距非常明显。这里有个实操建议哈希里所有字段的value都统一存字符串。如果存数字不要存整数类型而是存字符串形式的数字这样在跨语言、跨版本时兼容性最好。比如年龄存“28”而不是28。3.3 列表类型轻量消息队列的替代方案列表类型的典型操作是lpush配合rpop或者反过来rpush配合lpop形成先进先出的队列效果。// 从左侧入队 jedis.lpush(queue:email, email_task_1); jedis.lpush(queue:email, email_task_2); // 从右侧阻塞式出队最多等待10秒 ListString messages jedis.brpop(10, queue:email);brpop里的b是blocking阻塞的意思。没有消息时线程会阻塞等待直到超时。这在做简易消息队列时非常有用避免程序用轮询方式不停空转节省CPU资源。我在项目里用它处理过一批异步任务前台上传图片后把图片路径推入Redis列表后端消费者用brpop拉取任务执行缩略图生成。实现简单无需引入消息中间件几十万级别的任务量完全扛得住。列表类型还有个常见用途是“最新动态列表”。例如论坛最新的10条帖子每发一帖就lpush读取时用lrange(key, 0, 9)拿前10条性能远好于数据库ORDER BY id DESC LIMIT 10的查询。3.4 集合类型去重与随机抽奖集合类型的特点是元素唯一、无序。Jedis操作如下// 添加元素 jedis.sadd(activity:lottery:users, user_1, user_2, user_3); // 判断用户是否已参与 Boolean isJoined jedis.sismember(activity:lottery:users, user_1); // 获取集合大小 Long size jedis.scard(activity:lottery:users); // 随机弹出1个元素适合抽奖场景 String winner jedis.spop(activity:lottery:users); // 获取集合中所有元素 SetString allUsers jedis.smembers(activity:lottery:users);spop随机弹出一个元素并且从集合里移除天然适配抽奖不重复的需求。如果需求是“随机取出但不移除”就用srandmember。集合还常用来做交并集运算比如计算两个兴趣标签的重叠用户jedis.sadd(tag:java, user_1, user_2, user_3); jedis.sadd(tag:redis, user_2, user_3, user_4); // 交集既是Java爱好者又玩Redis的用户 SetString common jedis.sinter(tag:java, tag:redis);3.5 有序集合类型排行榜利器有序集合在集合基础上给每个元素关联了一个分数scoreRedis按分数排序。排行榜业务是它的经典战场。// 添加成员及分数 jedis.zadd(rank:game:week, 1500, player_a); jedis.zadd(rank:game:week, 2300, player_b); jedis.zadd(rank:game:week, 1800, player_c); // 按分数从高到低取出前3名 SetString top3 jedis.zrevrange(rank:game:week, 0, 2); // 查看某人的排名注意排名从0开始 Long rank jedis.zrevrank(rank:game:week, player_b); // 增加分数 jedis.zincrby(rank:game:week, 200, player_a);zrevrange按分数从大到小返回zrange则相反。zincrby在原有分数上加值适合实时更新玩家得分不用先取出再算再写回。有序集合还能做延时队列。把任务ID存进ZSet时间戳当分数起一个定时任务不断用zrangeByScore取当前时间之前到期的任务执行实现简单可靠。我在日志系统里用这个方案做过延迟重试队列效果比睡线程好得多。4. 连接池配置生产环境的必修课4.1 为什么不能每次请求都新建连接实践初期我犯过一个新手都会犯的错——每来一个请求就new Jedis(...)用完close()。跑到并发量一高Redis连接数直接飙到几千服务端报max number of clients reached线上告警响个不停。原因是创建TCP连接需要握手开销Redis每接受一个连接也要占用内存资源。频繁创建销毁连接不仅浪费还容易把Redis的连接数窗口撞破。正确的做法是使用JedisPool连接池预先创建一批连接放在池子里用的时候借用完归还。4.2 连接池参数的合理配置import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPoolConfig; JedisPoolConfig poolConfig new JedisPoolConfig(); // 最大连接数 poolConfig.setMaxTotal(50); // 最大空闲连接数 poolConfig.setMaxIdle(20); // 最小空闲连接数 poolConfig.setMinIdle(5); // 从池子里获取连接的最大等待时间 poolConfig.setMaxWaitMillis(3000); // 连接空闲多久后校验有效性 poolConfig.setTestWhileIdle(true); // 获取连接时是否校验有效性 poolConfig.setTestOnBorrow(false); // 归还连接时是否校验有效性 poolConfig.setTestOnReturn(false); JedisPool jedisPool new JedisPool(poolConfig, 127.0.0.1, 6379, 2000, yourpassword);参数怎么定这取决于你的应用并发模型。maxTotal建议设为预估并发峰值的1.5倍左右。比如单机应用最大同时处理200个请求每个请求只要一次Redis操作那maxTotal设成50到100足够不需要贪大。maxIdle控制空闲连接数量设得太大浪费资源太小则高峰期要频繁新建连接。一般设为maxTotal的一半。maxWaitMillis非常重要。设为3000毫秒表示从连接池取不到连接时最多等3秒超时抛出异常避免线程无限挂起。如果业务要求快速失败可以设成1000毫秒。testOnBorrow设为false。因为每借一次连接多一次PING检查在池子质量稳定时是纯浪费。依靠testWhileIdle做后台空闲检测就够了。4.3 正确使用连接池的方式try (Jedis jedis jedisPool.getResource()) { jedis.set(key, value); }这里有个重要的细节从池子里拿到的Jedis对象用完调用close()不是真的关闭连接而是归还给连接池。这个机制很多初学者不知道以为close就是断连其实Jedis内部做了判断——如果这个连接来自池子close触发归还逻辑如果是独立new出来的则真正关闭。如果使用的是Spring容器把JedisPool声明成一个单例Bean全局共享。高并发场景下同一个线程内也不要多次getResource()尽量在一个线程的整个业务逻辑里用同一个连接实例减少借还开销。5. 管道与事务批量操作的性能利器5.1 管道模式从RTT到合并发送业务中有个典型场景用户首次登录需要初始化多个key比如缓存用户信息、记录登录日志、添加日活统计。如果你一个一个执行每执行一个命令都要等待一次网络往返RTT假设每条命令2毫秒执行5条就是10毫秒100个用户登录就是1000毫秒损耗非常大。管道Pipeline就是把这些命令打包在一起发送Redis一次性处理完后批量返回结果。带宽占用大幅降低总体耗时可以减少一个数量级。Pipeline pipeline jedis.pipelined(); for (int i 0; i 100; i) { pipeline.set(batch:key: i, String.valueOf(i)); } // 批量执行并获取响应 ListObject responses pipeline.syncAndReturnAll();注意管道和普通命令混合使用时要小心。管道内的命令不会立刻执行必须调用sync()或syncAndReturnAll()触发。sync()不关心返回值适合纯写入场景syncAndReturnAll()返回每条命令的结果列表适合需要校验结果的场景。我在批量清理缓存时用管道体验尤其明显。有时一个用户下线要删除几十个session key逐条删除要几十毫秒用管道一把梭处理一万个key也就一两秒。5.2 事务MULTI、EXEC与DISCARDRedis事务和数据库事务不同它没有回滚机制但能保证一个事务块里的命令按顺序原子执行——所有命令要么都执行要么因为EXEC前出错都不执行。Transaction multi jedis.multi(); multi.set(account:10001:balance, 100); multi.decrBy(account:10001:balance, 10); multi.incrBy(account:20002:balance, 10); ListObject results multi.exec();multi()对应Redis的MULTI命令之后调用的方法不会立刻发送到Redis而是缓存在客户端。调用exec()后一次性发送并执行。中途想放弃调用discard()。这里有个常见误区Redis事务只能保证命令连续执行不能保证业务上的原子性。比如扣减账户A余额再增加账户B余额如果A扣减成功了B增加却因为参数错误失败Redis不会把A回滚。所以事务适合“保证命令不被其他客户端的命令插入”的场景而不是“全有或全无”的数据库事务。想要真正的事务回滚能力需要配合Lua脚本实现。Jedis支持直接执行Lua脚本这也是分布式场景下复杂操作的原子性保障方案。String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; Object result jedis.eval(luaScript, Collections.singletonList(lock:order:20001), Collections.singletonList(thread_id_abc));这段Lua脚本实现了“检查锁的值是否属于自己的线程ID是才删除”完美解决了分布式锁误删问题。eval的返回值如果是0说明锁已过期或已被别人持有无需处理。5.3 管道和事务的执行时机区别管道和事务经常被混为一谈但二者解决的问题完全不同。管道解决的是网络开销问题把多个命令一次发送节省的是RTT时间命令之间没有隔离性保证。事务解决的是原子执行问题保证一组命令不被其他客户端插入但不减少网络往返。实际项目中两者可以结合用管道包裹事务的入队和提交命令。Jedis里Transaction本身继承自MultiKeyPipelineBase天然支持管道特性。但大多数场景我建议分开使用逻辑更清晰。6. 哨兵模式与集群模式高可用架构下怎么用Jedis6.1 哨兵模式用JedisSentinelPool自动切换主从单机Redis一旦宕机整个缓存体系就瘫痪了。生产环境至少要上主从加哨兵。哨兵模式让Jedis能感知当前哪个节点是主节点主节点挂掉后自动切换到新的主节点。SetString sentinels new HashSet(); sentinels.add(10.0.1.10:26379); sentinels.add(10.0.1.11:26379); sentinels.add(10.0.1.12:26379); JedisSentinelPool pool new JedisSentinelPool(mymaster, sentinels, yourpassword); try (Jedis jedis pool.getResource()) { jedis.set(key, value); }这里传入的是哨兵节点的地址而不是Redis数据节点的地址这一点很多人容易写错。Jedis会通过哨兵获取当前主节点地址建立连接。主节点挂掉时哨兵会选举出从节点升级为新主节点连接池内部会自动更新连接目标。6.2 集群模式关注slot的分布Redis Cluster模式下数据按照CRC16算法分布到16384个槽位每个节点负责一部分槽位。Jedis提供了JedisCluster类import redis.clients.jedis.HostAndPort; import redis.clients.jedis.JedisCluster; SetHostAndPort clusterNodes new HashSet(); clusterNodes.add(new HostAndPort(10.0.1.20, 7000)); clusterNodes.add(new HostAndPort(10.0.1.21, 7000)); JedisPoolConfig poolConfig new JedisPoolConfig(); poolConfig.setMaxTotal(50); JedisCluster jedisCluster new JedisCluster(clusterNodes, 2000, 2000, 5, yourpassword, poolConfig); jedisCluster.set(cluster:key, cluster_value);JedisCluster只需要配置部分节点地址即可它内部维护了槽位与节点的映射关系。当操作key时它计算出key对应的槽位再定位到正确的节点整个过程对调用方透明。集群模式有几个限制写代码时要注意不支持select库操作集群只有db 0不支持多key操作除非所有key都在同一槽位事务和Lua脚本中的多key操作也必须保证key在同一个槽位业界通用的做法是用大括号定义hash tag比如{user:10001}:name和{user:10001}:age这样两个key会被分配到同一个槽位允许在一个事务或脚本里操作。6.3 高可用场景的选型思路如果项目规模还小单机足够直接JedisPool。如果要求高可用但数据量不大哨兵模式最简单。如果数据量巨大、需要水平扩展集群模式是必然选择。我的经验是不要过度设计。某早期项目Redis数据总量不到100MB硬是搭了集群运维复杂度翻了几倍收益却为零。先从单机或哨兵开始当单机内存不满足、需要水平扩展时再迁移到集群。7. 常见问题排查与避坑指南7.1 连接超时与阻塞排查Jedis操作中报JedisConnectionException: java.net.SocketTimeoutException是最常见的故障之一。排查步骤按顺序来第一确认Redis服务端的socketTimeout配置是否过小。Jedis构造时有个超时参数比如new JedisPool(config, host, port, timeout)这个timeout是连接超时加读写超时的总和。如果业务上有大value的读写比如几百KB的JSON默认2000毫秒可能不够适当调高到5000或8000。第二确认是否为慢查询拖住了Redis服务端。用Redis的SLOWLOG GET命令查看最近的慢命令如果有很多KEYS *这类命令全表扫描会阻塞服务端导致所有请求变慢。强烈建议生产环境禁用KEYS *命令改用SCAN。第三检查网络层面是否存在跨机房访问。Redis的RTT往返时间通常要求在1毫秒以内如果客户端和服务端跨地域机房访问RTT可能到几十毫秒这时候怎么优化客户端代码都没用必须从架构上让客户端和服务端同机房部署。7.2 连接池耗尽与timeout等待报错Could not get a resource from the pool时大多数人第一反应是调大maxTotal。但正确做法是先分析为什么连接被占满。用redis-cli CLIENT LIST查看服务端实际连接数如果连接数远小于maxTotal说明有连接泄漏——代码里从池子获取连接后业务抛异常时没有执行close()归还连接导致池子里可用连接越来越少。这里的一个深刻教训连接归还必须放在finally块或者用Java 7的try-with-resources语法。// 推荐写法try-with-resources try (Jedis jedis jedisPool.getResource()) { jedis.set(key, value); } catch (Exception e) { // 业务异常处理连接会被自动归还 }7.3 大Key与热Key问题有些业务把用户的所有动态信息拼成一个大JSON塞进一个key里value大小可能超过1MB。这种大key有几个危害网络传输慢、Redis阻塞风险高比如DEL一个超大key会阻塞服务端、内存碎片增多。解决方案是数据拆分。我处理过的一个案例某团队把用户头像、昵称、等级、积分全部塞进一个key结果单key最大达到3MB。拆分成用户基本信息哈希和用户统计信息哈希多个key之后单个key控制在几十KB性能提升了一个量级。热Key问题则体现在某个key被超高并发读取比如微博热搜第一名。单一节点承受不住解决思路有本地缓存兜底、把热key复制为多份如hotkey:1到hotkey:10分散读压力、或者引入多级缓存架构。7.4 序列化方式不一致导致的数据读不出来Jedis本身只负责传输字节它不关心你的对象怎么序列化。如果写入时用JSON序列化读取时忘了反序列化拿到的就是一串JSON字符串如果写入时用的Java原生ObjectOutputStream读取时类定义变了直接抛ClassNotFoundException。我的建议是统一在项目里封装一个工具类操作Redis时自动完成序列化和反序列化。不要直接暴露Jedis原生的set(byte[])接口这样容易在团队里造成序列化方式混用。public class RedisCacheUtil { private static final JedisPool POOL new JedisPool(); public static void setString(String key, String value) { try (Jedis jedis POOL.getResource()) { jedis.set(key, value); } } public static void setObject(String key, Object obj) { try (Jedis jedis POOL.getResource()) { String json JSON.toJSONString(obj); jedis.set(key, json); } } public static T T getObject(String key, ClassT clazz) { try (Jedis jedis POOL.getResource()) { String json jedis.get(key); if (json null) return null; return JSON.parseObject(json, clazz); } } }7.5 常见问题速查表报错现象可能原因解决方案SocketTimeoutException超时配置过小、网络延迟高调大超时参数检查网络环境Could not get a resource连接池耗尽、连接泄漏检查finally归还逻辑观察CLIENT LISTNOAUTH Authentication requiredRedis设置了密码但客户端未传入jedis.auth(password)或在池化配置中指定ERR max number of clients reached服务端连接数达到上限调大maxclients或减少连接池maxTotalJedisDataException: WRONGTYPE对key使用了错误的数据类型命令确认Redis中该key的实际类型OutOfMemoryError读取了超大key拆分大key设置Jedis的maxBuffer限制8. 从Jedis到更高级的抽象我的实践体会操作Redis到最后绕不开一个工程化问题代码里到处散落Jedis调用key的命名不规范团队里你写userName我写user_name同一个key某个模块写入字符串另一个模块当哈希读线上排查时非常痛苦。我的经验是建立统一的缓存管理模块。核心做三件事第一集中管理所有key的命名规则。用业务域:实体:ID:字段的规范比如user:profile:10001:basic。把常量集中在一个类里避免字符串散落各处。第二针对高频业务做统一封装。比如缓存读取使用“读缓存-未命中-回源数据库-写回缓存”的模板方法public T T getWithLoad(String key, SupplierT loader, int expireSeconds) { T cached getObject(key, ...); if (cached ! null) return cached; T value loader.get(); // 从数据库加载 if (value ! null) { setObject(key, value, expireSeconds); } return value; }这个模板方法还能加上一个随机过期时间防止批量key同时到期导致缓存雪崩。第三监控Redis的命令耗时。可以继承Jedis类重写关键方法记录耗时更优雅的方式是用AOP切面拦截缓存方法的调用。我在自己的项目里只是简单地给每个缓存方法打了日志就能发现问题——某个接口的Redis耗时突然从5毫秒涨到80毫秒顺着日志排查发现是有人写了个KEYS *命令。使用Jedis操作Redis这件事本身并不复杂但把细节打磨好、把工程规范建立起来才是让稳定性和性能拉开差距的地方。这个库长寿不是没有原因的它把复杂留给了自己把简单交给了开发者。等你用顺手了再回头看那些报错和调优记录会发现每一步坑都踩得值得。