批量写Redis这件事看似简单,但选错方案可能让性能差出好几倍。有一次线上活动需要预写入50万个缓存key,第一版代码用了循环调用set的方式,压测时QPS惨不忍睹;后来换成Pipeline,吞吐直接翻了二十倍以上。而团队里另一位同学坚持认为Mset才是批量写入的正解,理由是它是一条命令、一次网络往返。两种说法其实都对,也都不全对,关键要看你的数据特征和业务要求。

从RESP协议看两者的本质区别
先搞清楚底层发生了什么。Redis客户端与服务端之间使用RESP协议通信,普通模式下每执行一条命令,客户端发送请求、等待服务端返回响应,一来一回就是一个RTT(Round-Trip Time)。假如你要写1万个key,循环调用set就意味着1万次网络往返,即使每次RTT只有0.5毫秒,光网络等待就要5秒,而Redis本身执行一条set命令可能只需要几微秒,绝大部分时间都浪费在了等待网络回包上。
Pipeline的做法是客户端把1万条set命令按顺序打包,一次性(或分批)发送给服务端,服务端依次执行后把所有响应一起返回。这样1万条命令只需要一次或少数几次网络往返,网络开销被大幅摊薄。需要注意的是,Pipeline减少的是网络RTT,并没有减少命令的执行次数,服务端还是要逐条执行这1万条set。
Mset则走的是另一条路。它是Redis内置的一条批量命令,格式为MSET key1 value1 key2 value2 ...,客户端发送的是一条命令,服务端解析后在一个命令调用内完成所有key的写入。从协议层面看,它天然只有一次往返,而且是单命令执行,语义更紧凑。抓包对比一下就能看到明显差异:Pipeline发送的是一个包含N条命令的请求流,Mset发送的是一条巨大的命令行。
功能差异:Mset的三个硬限制
第一个限制是值类型。Mset只能写入string类型的值,如果你的value是hash、list或者需要序列化的复杂对象,虽然可以序列化后当string存,但如果你本来就想用hash结构存储字段,Mset就无能为力了,而Pipeline可以混搭任意命令类型。
第二个限制是不能设置过期时间。这是Mset被诟病最多的一点,它不支持EX或PX参数。如果你的缓存需要TTL,用Mset写入后还得再批量调用expire,一来一回反而复杂了。而Pipeline里可以直接用set key value ex 3600这样的带过期参数命令,一步到位。
第三个限制与原子性相关。Mset本身是原子的,要么全部写入成功,要么在出错时不写入,这个特性在需要保证一批key一致写入时有价值。而Pipeline不保证原子性,它只是批量传输,中间某条命令失败不影响其他命令的执行,其他客户端的命令也可能穿插在其中执行。如果你需要批量写入的隔离性,那应该用MULTI/EXEC事务或者Lua脚本,而不是Pipeline。
实测代码:不同数据量下的耗时对比
用Jedis写一个简单的测试,分别用Mset、Pipeline和循环set三种方式写入1万、10万个key,观察总耗时。测试环境为本地Redis,网络延迟极低,生产环境跨机房时差距会进一步放大。
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import java.util.ArrayList;
import java.util.List;
public class BatchWriteTest {
public static void main(String[] args) {
Jedis jedis = new Jedis("127.0.0.1", 6379);
int total = 100000;
// 方式一:循环set,性能最差
long start = System.currentTimeMillis();
for (int i = 0; i < total; i++) {
jedis.set("key:normal:" + i, "value" + i);
}
System.out.println("循环set耗时: " + (System.currentTimeMillis() - start) + "ms");
// 方式二:Mset,单命令批量写入
start = System.currentTimeMillis();
for (int i = 0; i < total; i += 1000) {
List<String> kvList = new ArrayList<>();
for (int j = i; j < i + 1000; j++) {
kvList.add("key:mset:" + j);
kvList.add("value" + j);
}
jedis.mset(kvList.toArray(new String[0]));
}
System.out.println("Mset耗时: " + (System.currentTimeMillis() - start) + "ms");
// 方式三:Pipeline,命令打包传输
start = System.currentTimeMillis();
Pipeline pipe = jedis.pipelined();
for (int i = 0; i < total; i++) {
pipe.set("key:pipe:" + i, "value" + i);
}
pipe.sync();
System.out.println("Pipeline耗时: " + (System.currentTimeMillis() - start) + "ms");
}
}典型结果是:循环set耗时约8秒,Pipeline耗时约400毫秒,Mset分批调用耗时约500毫秒。可以看出Pipeline和Mset性能处于同一量级,都比逐条写入快一个数量级以上。两者之间几十毫秒的差距在实际业务中通常不是决定性因素,功能限制和内存问题才是。
还有一个容易被忽略的坑是内存。Pipeline会把所有响应缓存在服务端内存中,直到客户端读取,如果一次性塞入几十万条命令,Redis的输出缓冲区会暴涨,甚至触发客户端被断开。所以大批量写入时建议分批,每批控制在1000到5000条命令比较稳妥。Mset也有类似问题,一条命令携带过多的key value会撑大协议解析缓冲区,同样建议拆分调用。
选型建议与常见组合方案
如果value全是string且不需要TTL,比如写入配置快照、预热静态数据,Mset是最简洁的选择,代码可读性好,还能享受原子性。如果需要设置过期时间,或者要写入hash、zset等复杂结构,直接用Pipeline搭配对应的命令,例如hset、setex,一次搞定。
实际业务中最常见的组合是分批Pipeline加过期时间。下面是一段带TTL的分批写入模板,可以作为通用工具方法:
public void batchWriteWithTtl(Jedis jedis, Map<String, String> data, int ttlSeconds) {
int batchSize = 2000;
List<String> keys = new ArrayList<>(data.keySet());
for (int i = 0; i < keys.size(); i += batchSize) {
Pipeline pipe = jedis.pipelined();
int end = Math.min(i + batchSize, keys.size());
for (int j = i; j < end; j++) {
String key = keys.get(j);
pipe.setex(key, ttlSeconds, data.get(key));
}
pipe.sync();
}
}另外提醒一点,如果你在用Spring Data Redis,executePipelined方法封装好了Pipeline细节;在Lettuce驱动中甚至可以开启自动批处理,普通调用也会被底层自动聚合,这种情况下显式Pipeline的收益会变小。而Mset在任何客户端里都有对应API,行为一致,没有驱动层面的差异。
总结一下选型口诀:纯string无过期选Mset,要TTL或混合结构选Pipeline,要求严格一致写入选MULTI事务或Lua,超大批量务必分批提交。把这几条规则记牢,批量写入的性能和稳定性基本就不会出问题了。
Redis PipelineMset批量写入Redis性能优化修改时间:2026-09-16 07:00:37