Administrator
发布于 2020-08-23 / 5292 阅读
100

Redis 管道与 Lua 脚本:批量操作的性能优化

一个批量接口慢在哪:不是 Redis 慢,是网络慢

八月下旬优化一个批量查询接口。它要一次读 200 个商品的库存,我一开始的写法很直白:

public Map<String, Integer> batchGetStock(List<String> skuIds) {
    Map<String, Integer> result = new HashMap<>(skuIds.size());
    for (String skuId : skuIds) {
        String val = stringRedisTemplate.opsForValue().get("stock:" + skuId);
        result.put(skuId, val == null ? 0 : Integer.parseInt(val));
    }
    return result;
}

200 次循环,接口耗时 42ms。我觉得 Redis 一次 GET 也就几十微秒,200 次不该这么慢。加了耗时埋点之后发现,Redis 命令本身的执行时间加起来才 1.6ms,剩下 40ms 全在等网络上。

算一下往返开销

redis-cli --latency 测一下客户端到 Redis 的往返延迟:

$ redis-cli -h 10.20.1.31 --latency -i 5
min: 0, max: 3, avg: 0.19 (milliseconds)

平均 0.19ms,看着很小。但这是单次往返(RTT)。200 次串行调用就是:

200 × 0.19ms = 38ms

加上序列化、连接获取这些,42ms 就对上了。这里的关键认知是:Redis 的单次命令执行是微秒级的,但网络往返是百微秒级的,两者差一个数量级。批量操作时瓶颈几乎总在网络而不是 Redis 本身。

tcpdump 抓包验证过,200 次 GET 对应 200 个请求包 + 200 个响应包:

$ tcpdump -i eth0 -nn -tt 'port 6379 and host 10.20.1.31' -c 400 | head -20
1598162401.118342 IP 10.20.1.12.48210 > 10.20.1.31.6379: Flags [P.], seq 1:43, length 42
1598162401.118531 IP 10.20.1.31.6379 > 10.20.1.12.48210: Flags [P.], seq 1:7, length 6
1598162401.118744 IP 10.20.1.12.48210 > 10.20.1.31.6379: Flags [P.], seq 43:86, length 43
...

相邻两个请求的时间差稳定在 190 微秒左右,就是等前一个响应回来才发下一个。

方案一:MGET 解决读批量

读的场景有现成的批量命令。200 个 key 一次 MGET 拿回来:

public Map<String, Integer> batchGetStock(List<String> skuIds) {
    List<String> keys = skuIds.stream()
            .map(s -> "stock:" + s)
            .collect(Collectors.toList());
    List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
    Map<String, Integer> result = new HashMap<>(skuIds.size());
    for (int i = 0; i < skuIds.size(); i++) {
        String v = values.get(i);
        result.put(skuIds.get(i), v == null ? 0 : Integer.parseInt(v));
    }
    return result;
}

耗时从 42ms 降到 0.8ms。一次往返,快 50 倍。

但 MGET 有两个限制:key 数量别太大(我们一次最多 500 个,超过就切批,避免单次请求包过大阻塞其他命令);所有 key 必须在同一个 Redis 实例上(集群模式下如果 key 分散在不同 slot,multiGet 在 Lettuce 里会自动按 slot 分组发多次,效果打折)。

方案二:Pipeline 解决写批量

写操作没有 MSET 那么通用(MSET 只能设字符串,且无法带过期时间、无法做自增),这时候用 pipeline。

pipeline 的原理:把多条命令攒在客户端缓冲区,一次性打包发给 Redis,Redis 依次执行后把所有响应一次性返回。从 200 次往返变成 1 次往返。

public void batchSetStock(List<StockItem> items) {
    stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
        for (StockItem item : items) {
            byte[] key = ("stock:" + item.getSkuId()).getBytes(StandardCharsets.UTF_8);
            byte[] val = String.valueOf(item.getStock()).getBytes(StandardCharsets.UTF_8);
            connection.setEx(key, 1800, val);       // SET key val EX 1800
        }
        return null;                                 // 返回值会被忽略
    });
}

200 次 SETEX,从 44ms 降到 1.1ms。

如果要拿每条命令的返回结果:

List<Object> results = stringRedisTemplate.executePipelined(
        (RedisCallback<Object>) connection -> {
            for (String skuId : skuIds) {
                connection.incrBy(("stock:" + skuId).getBytes(), -1);
            }
            return null;
        });
// results 里按顺序放着每条 INCRBY 的返回值

pipeline 的几个注意点:

  • pipeline 不保证原子性。它只是批量传输,中间可能穿插别的客户端的命令。需要原子性就得用事务或者 Lua。
  • 攒的命令别太多。一次塞 10 万条命令,客户端缓冲区和 Redis 的输入缓冲区都会涨,而且这条大命令会阻塞 Redis 处理其他请求。我们按 500 条切批。
  • 结果是一次性返回的,会占用客户端内存。批量查询结果大时(比如 pipeline 里塞 5000 个 HGETALL),小心 OOM。

实测不同批大小的耗时(200 条命令,本地网络 0.19ms RTT):

方式耗时往返次数
逐条执行42ms200
pipeline(一批 200)1.1ms1
pipeline(切批,每批 50)1.6ms4
pipeline(一批 5000)38ms1(但 Redis 阻塞 30ms)

最后一行是重点:批太大反而慢,因为 Redis 单线程,执行这 5000 条命令期间其他客户端全在排队。pipeline 是把往返成本转移成了 Redis 的处理时间,批太大就变成阻塞了

方案三:Lua 脚本保证原子性

前面两种方案都不保证原子性。有个场景必须要原子:限量秒杀的库存扣减,要先判断库存够不够,够才扣。写成两条命令就出问题了:

// 错误:GET 和 DECRBY 之间可能被别的线程插进来,导致超卖
int stock = Integer.parseInt(redis.get("stock:" + skuId));
if (stock >= num) {
    redis.decrBy("stock:" + skuId, num);     // 中间这一刻库存可能已经被别人扣光了
    return true;
}
return false;

压测 500 并发抢 1000 件库存,最后卖出 1043 件,超卖 43 件。

用 Lua 脚本解决。Redis 执行 Lua 脚本是单线程原子的,脚本执行期间不会有其他命令插进来:

-- deduct_stock.lua
-- KEYS[1]: 库存 key
-- ARGV[1]: 扣减数量
-- ARGV[2]: 兜底的过期时间,防止库存 key 永不过期
local key   = KEYS[1]
local num   = tonumber(ARGV[1])
local ttl   = tonumber(ARGV[2])

local stock = tonumber(redis.call('GET', key))
if stock == nil then
    return -1                    -- key 不存在,说明没初始化
end
if stock < num then
    return -2                    -- 库存不足
end
redis.call('DECRBY', key, num)
redis.call('EXPIRE', key, ttl)
return stock - num               -- 返回扣减后的剩余库存

Java 这边用 RedisScript + execute

@Configuration
public class LuaScriptConfig {
    @Bean
    public DefaultRedisScript<Long> deductStockScript() {
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setScriptSource(new ClassPathResourceScriptSource(
                new ClassPathResource("lua/deduct_stock.lua")));
        script.setResultType(Long.class);
        return script;
    }
}

@Service
public class StockService {
    @Autowired private StringRedisTemplate redis;
    @Autowired private DefaultRedisScript<Long> deductStockScript;

    public boolean deduct(String skuId, int num) {
        List<String> keys = Collections.singletonList("stock:" + skuId);
        Long ret = redis.execute(deductStockScript, keys,
                String.valueOf(num), "1800");
        if (ret == null) return false;
        if (ret == -1L) { log.warn("库存未初始化, skuId={}", skuId); return false; }
        if (ret == -2L) { return false; }        // 库存不足
        if (ret == 0L) { /* 触发售罄事件,更新 DB */ }
        return true;
    }
}

同样的压测,超卖为 0,QPS 还能到 21000(单 key,因为 Lua 脚本在 Redis 内部执行,只有一次网络往返)。

脚本缓存:别每次都传脚本全文

这是我踩的一个坑。一开始我用 DefaultRedisScriptClassPathResourceScriptSource,以为 Spring 会帮忙缓存。后来抓包发现每次调用都在传完整的脚本文本——几百字节的 Lua 代码,每次都发一遍。

原因是 Redis 的脚本缓存机制:EVAL 命令要把脚本全文发过去,Redis 计算 SHA1 后缓存;EVALSHA 只发 SHA1 摘要,Redis 用摘要去缓存里找。如果缓存里没有,返回 NOSCRIPT 错误,客户端再退化成 EVAL

Lettuce(Spring Boot 2.3 默认的 Redis 客户端)的 RedisScriptingCommands 确实做了优化:优先发 EVALSHA,收到 NOSCRIPT 才发 EVAL。用 redis-cli monitor 看下实际执行的命令:

$ redis-cli -h 10.20.1.31 monitor
1598163001.112233 [0 10.20.1.12:48210] "EVALSHA" "a4f2c8b1e9d3..." "1" "stock:10086" "2" "1800"
1598163001.112401 [0 10.20.1.12:48210] "EVALSHA" "a4f2c8b1e9d3..." "1" "stock:10087" "1" "1800"
1598163001.112588 [0 10.20.1.12:48210] "EVALSHA" "a4f2c8b1e9d3..." "1" "stock:10088" "3" "1800"

确实走的是 EVALSHA。但 JDK 8 + Lettuce 5.3 的组合有个 bug:如果 Redis 重启或者执行了 SCRIPT FLUSH,缓存全丢,客户端会收到 NOSCRIPT 并退化成 EVAL,这个退化是不可见的,只有并发高的那一下会慢。我们压测时看到过 TP99 突然从 3ms 跳到 90ms,就是 Redis 主备切换后脚本缓存丢失导致的。

解决办法是在应用启动时主动加载脚本:

@PostConstruct
public void preloadScripts() {
    // 用 SCRIPT LOAD 把脚本灌进 Redis 缓存,返回 SHA1
    String sha = redis.execute((RedisCallback<String>) connection ->
            connection.scriptLoad(deductStockScript.getScriptAsString().getBytes()));
    log.info("库存扣减脚本已预加载, sha1={}", sha);
}

另外确认一下脚本确实在缓存里:

redis> SCRIPT EXISTS a4f2c8b1e9d3f6a8b2c5d7e9f1a3b5c7d8e9f0a1
1) (integer) 1

redis> SCRIPT FLUSH          # 清空脚本缓存(慎用,会影响所有客户端)
redis> SCRIPT KILL           # 杀死正在执行的脚本(脚本没写操作时才管用)

Lua 脚本的几条硬规矩

这些是踩完坑之后总结的:

  • 脚本不能用随机数和系统时间。Redis 要求脚本必须是确定性的,否则主从复制会因为执行结果不一致而数据错乱。Redis 5.0 之后默认开启 replication 的 effects replication(只复制写命令),这个限制松动了一些,但别依赖它。
  • 脚本不能执行太慢。Lua 脚本在 Redis 里是单线程执行的,一个跑 500ms 的脚本会让所有其他客户端等 500ms。我们给 Lua 里加了个循环上限,超过 1000 次迭代就返回错误。
  • KEYS 和 ARGV 要分开传KEYS 用于 Redis 集群做 slot 路由,写死在脚本里或者放错位置,集群模式下会报 CROSSSLOT Keys in request don't hash to the same slot
  • 脚本里不要做耗时的 KEYS 操作。见过有人在 Lua 里写 redis.call('KEYS', 'user:*'),几十万 key 直接把 Redis 卡死。要遍历用 SCAN,或者干脆别在脚本里做。

三种方案怎么选

场景方案是否原子
批量读同一类型 keyMGET / HMGET否(单条命令本身是原子执行)
批量写,不要求原子pipeline
多步操作要原子Lua 脚本
要原子 + 要回滚MULTI/EXEC 事务是(但弱,不支持条件回滚)

补充说明一下 Redis 的事务:它的 MULTI/EXEC 不支持条件判断,也不支持回滚(某条命令失败,后面的照样执行,只有语法错误才整体不执行)。所以真要做"检查再执行"这种原子逻辑,只能用 Lua。这也是为什么 Lua 在 Redis 里地位这么重要。

留个问题

关于《Redis 管道与 Lua 脚本:批量操作的性能优化》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考