广告位 · 页头(后台-模块管理-新建模块后替换此区域)

Redis MGET批量获取多个键值怎么用?性能对比与避坑详解

在Web应用的缓存层里,批量读取是最常见的访问模式:商品首页要一次取出几十个商品的缓存数据,社交信息流要拉取一批用户的资料,购物车结算要汇总多条商品价格。如果用循环GET的方式逐个取键,一百个键就要经历一百次网络往返,整体延迟会被网络RTT成倍放大。Redis提供的MGET命令就是为这种场景准备的,它允许在一次请求里传入多个键,一次性拿到所有对应的值,把N次往返压缩成1次。不过MGET并不是把一堆键丢进去就完事,返回值的对齐规则、键不存在时的表现、大批量带来的阻塞风险、集群模式下的槽位限制,都有一些容易踩坑的细节,这篇文章就把这些内容一次讲清楚。

Redis MGET批量获取多个键值怎么用?性能对比与避坑详解

MGET命令基础:语法、返回值与对齐规则

MGET的语法非常简单:MGET key [key ...],命令后面跟一个或多个键名,Redis会严格按照传入顺序返回每个键对应的值,时间复杂度是O(N),N为传入键的数量。先看一个最基础的例子:

127.0.0.1:6379> SET user:1 "tom"
OK
127.0.0.1:6379> SET user:2 "jerry"
OK
127.0.0.1:6379> MGET user:1 user:2 user:999
1) "tom"
2) "jerry"
3) (nil)

这里有一个非常关键的规则:结果列表的长度永远等于传入键的数量,位置一一对应。user:999并不存在,但它对应的位置不会缺席,而是返回nil。这个设计让调用方不需要额外查询哪些键存在,直接遍历返回结果就能判断。不少刚接触MGET的同学会以为不存在的键会被跳过,结果按索引取值时发生错位,排查起来非常痛苦,这一点一定要记住。

另一个容易被忽略的细节是类型限制。MGET只处理字符串类型的值,如果某个键存储的是哈希、列表等复杂类型,对应位置同样返回nil,命令本身不会失败,这一点和单键GET的行为不同,GET遇到非字符串类型会直接报WRONGTYPE错误。所以当MGET返回大量nil时,除了键真的不存在,还要考虑键类型存错了的可能。另外,允许传入重复的键,Redis会老老实实返回多份相同的值,一般没什么用处,但也不会出错。

循环GET、Pipeline与MGET的性能对比

三者差距的核心在于网络往返次数。假设客户端到Redis的单次往返耗时1毫秒,取1000个键:循环GET需要1000次往返,仅网络等待就要1秒;Pipeline把1000条GET命令打包在一次往返里发送;MGET则把1000个键合并成一条命令,同样只占一次往返。三者的执行结果一致,耗时却差了两个数量级。

方式网络往返次数命令条数典型场景
循环GETN次N条键数量极少、无批量需求
Pipeline1次N条需要混合多种命令或不同类型操作
MGET1次1条纯批量字符串读取

可以用一段简单的脚本直观感受差距:

import redis
import time
r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True)
keys = ['key:%d' % i for i in range(1000)]
for k in keys:
    r.set(k, 'value')
# 方式一:循环GET,1000次网络往返
start = time.time()
for k in keys:
    r.get(k)
print('循环GET耗时: %.3f 秒' % (time.time() - start))
# 方式二:一次MGET,1次网络往返
start = time.time()
r.mget(keys)
print('MGET耗时: %.3f 秒' % (time.time() - start))

在本地环境下,这段代码的典型输出是循环GET耗时零点几秒、MGET耗时几毫秒,差距几十倍;如果Redis部署在跨机房或公网环境,RTT从0.1毫秒涨到几毫秒,差距会进一步拉大到几百倍。那MGET和Pipeline该怎么选?如果是纯粹的批量字符串读取,MGET更优:它只有一条命令,协议解析开销更小,服务端处理路径更短。Pipeline的优势在于灵活性,可以在一次往返里混合GET、SET、EXPIRE等不同命令,或者对非字符串类型做批量操作。两者并不冲突,实际项目里按需求选即可。

大批量MGET的阻塞风险与分批策略

MGET虽快,但不是越大越好。Redis处理命令的主线程是单线程的,一条包含几万个键的MGET在执行期间会独占主线程,其他客户端的命令全部排队等待,表现出来就是Redis间歇性卡顿。除了命令执行本身,几万个值一次性序列化成响应报文,网络发送阶段同样会阻塞。如果单个值的体积还不小,比如缓存了整页JSON,单个几百KB,问题会更严重。

工程上的通用做法是分批:把大键列表切成每批几百个键,循环调用MGET。批大小的经验值在500到1000之间,既保证了批量收益,又把单次命令的耗时控制在毫秒级。下面是一个Java实现的分批工具方法:

public static <T> List<List<T>> partition(List<T> list, int size) {
    List<List<T>> parts = new ArrayList<>();
    for (int i = 0; i < list.size(); i += size) {
        parts.add(list.subList(i, Math.min(i + size, list.size())));
    }
    return parts;
}
// 每批最多500个键,避免单次MGET过大造成阻塞
List<List<String>> batches = partition(allKeys, 500);
List<String> result = new ArrayList<>(allKeys.size());
for (List<String> batch : batches) {
    String[] arr = batch.toArray(new String[0]);
    result.addAll(jedis.mget(arr));
}

分批之间还可以插入微小的间隔或者结合异步执行,把压力摊开。另外要留意值本身的体积:批量读取大值时,响应报文的大小约等于所有值之和,几百个1MB的值就是几百MB的响应,网卡和客户端反序列化都会成为瓶颈。这种情况与其优化MGET,不如重新审视缓存粒度,把大JSON拆成更小的结构,或者考虑用压缩减小体积。

客户端实战:Jedis、Spring Boot与Python中的MGET

主流客户端都对MGET有直接支持。Jedis里对应mget方法,传入字符串数组,返回List<String>。这里最需要注意的是null值处理:不存在的键在返回列表里对应位置是null而不是空字符串,直接使用会抛空指针。

Jedis jedis = new Jedis("127.0.0.1", 6379);
List<String> keys = Arrays.asList("user:1", "user:2", "user:999");
List<String> values = jedis.mget(keys.toArray(new String[0]));
for (int i = 0; i < keys.size(); i++) {
    String value = values.get(i);
    if (value == null) {
        // 缓存未命中,走数据库回源逻辑
        System.out.println(keys.get(i) + " 未命中");
    } else {
        System.out.println(keys.get(i) + " = " + value);
    }
}

Spring Boot项目里用RedisTemplate更省事,opsForValue().multiGet(keys)封装了MGET,配合RedisTemplate<String, String>使用时返回值就是字符串列表:

@Service
public class CacheService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    /**
     * 批量读取缓存,未命中的键对应位置返回null
     */
    public List<String> batchGet(List<String> keys) {
        if (keys == null || keys.isEmpty()) {
            return Collections.emptyList();
        }
        List<String> values = redisTemplate.opsForValue().multiGet(keys);
        return values == null ? Collections.emptyList() : values;
    }
}

Python的redis-py同样提供了mget,返回列表中未命中的键对应None。一个常见的下游模式是批量读取后统一回源:先收集所有None位置的键,一次性查数据库,再用mset或管道写回缓存,整个流程只有两次网络往返,这是缓存批量读取的标准范式。

Redis Cluster模式下的CROSSSLOT限制

单机主从架构下MGET没有额外限制,但集群模式是另一回事。Redis Cluster把键空间分成16384个槽,每条命令涉及的键必须落在同一个槽上,MGET也不例外。如果传入的键分散在不同槽,服务端会直接报错:CROSSSLOT Keys in request don't hash to the same slot。这是集群下MGET最常见的问题,不少项目从单机迁移到集群后批量查询突然全部失败,原因就在这里。

解决思路有两种。第一种是哈希标签:键名中用大括号包裹的部分才会参与槽计算,比如user:{1001}:profile和user:{1001}:orders会落在同一个槽,可以放心MGET。这种方式适合同一业务实体天然需要一起读取的场景,但要克制使用,如果大量键都打上同一个标签,数据会集中到单个节点,集群的负载均衡就形同虚设了。

第二种是客户端分组的思路:由客户端计算每个键的槽位,把同槽的键归为一组,分别向对应节点发送MGET,最后按原始顺序拼装结果。Lettuce客户端的mget在集群模式下就是这么做的,对使用者完全透明;JedisCluster则没有内置这个能力,遇到跨槽多键操作会直接抛异常,需要自己按CRC16(key) mod 16384分组后分别调用。如果项目允许选型,集群场景优先考虑Lettuce或者Spring Data Redis加Lettuce的组合,能省掉不少自己分组的代码。

实践要点总结

把前面几部分的内容收敛一下,可以提炼出一份批量读取的检查清单:

  • 返回结果与传入键严格按位置对齐,不存在的键对应nil,客户端必须做判空处理,避免空指针。
  • 单批键数量建议控制在500到1000个,超出先分批再调用,防止大命令长时间占用Redis主线程。
  • 纯字符串批量读取优先MGET,需要混合多种命令时改用Pipeline,两者都远优于循环GET。
  • 留意值的体积,批量读取大值时响应报文可能非常大,必要时拆小缓存结构或启用压缩。
  • 集群模式下注意CROSSSLOT限制,用哈希标签或客户端按槽分组解决,同时避免哈希标签造成数据倾斜。

批量读取看似只是一个小命令的使用问题,背后牵扯的却是网络模型、单线程架构和集群分布机制。把一次业务请求里的N次缓存往返压缩成一两次,往往就是高并发系统里性价比最高的优化,值得在每个用到缓存批量读取的项目里认真做好。

广告位 · 文章底部(后台-模块管理替换此区域)