Skip to content

Redis 慢命令阻塞与处理

你的 Redis 突然卡住了。

所有命令都在等待,客户端超时。

可能是慢命令在作怪。

什么是慢命令?

慢命令是指执行时间过长的命令,会阻塞 Redis 主线程。

正常命令:
请求1 ──▶ 执行(10ms) ──▶ 返回
请求2 ──▶ 执行(10ms) ──▶ 返回
请求3 ──▶ 执行(10ms) ──▶ 返回

慢命令阻塞:
请求1 ──▶ 执行(10ms) ──▶ 返回
请求2 ──▶ 执行(10ms) ──▶ 返回
请求3 ──▶ 执行(10s) ──────────────▶ 返回

                      所有请求卡住!

哪些命令是慢命令?

O(n) 及以上的命令

命令时间复杂度风险
KEYS patternO(n)
SMEMBERSO(n)
LRANGEO(m)
HGETALLO(n)
GETALLO(n*m)
SORTO(n log n)
ZRANGEBYSCOREO(log n + m)

大 key 操作

类型操作风险
StringGET/SET 大 value
ListLRANGE/LPUSH 大列表
HashHGETALL 大 Hash
SetSMEMBERS 大集合
ZSetZRANGE 大有序集合

慢命令的成因

场景一:KEYS 命令

java
/**
 * KEYS 命令:遍历所有 key
 * 
 * 在有 1000 万 key 的 Redis 中:
 * 
 * redis-cli KEYS "user:*"
 * 
 * 时间:可能需要几秒甚至几十秒
 * 阻塞:期间所有命令都无法执行
 */
public class KeysCommand {
    
    // 错误:在线上使用 KEYS
    public void badPractice() {
        // 危险!
        Set<String> keys = jedis.keys("user:*");
        // 在大数据库中,这会冻结 Redis
    }
    
    // 正确:使用 SCAN
    public void goodPractice() {
        ScanParams params = new ScanParams().match("user:*").count(100);
        String cursor = "0";
        
        do {
            ScanResult<String> result = jedis.scan(cursor, params);
            cursor = result.getCursor();
            
            // 处理这一批 key
            List<String> keys = result.getResult();
            process(keys);
            
        } while (!"0".equals(cursor));
    }
}

场景二:大 List 操作

java
/**
 * 大 List 操作
 */
public class BigListOperation {
    
    // 错误:读取整个 List
    public void badPractice() {
        // 假设有 100 万元素的 List
        List<String> all = jedis.lrange("big_list", 0, -1);
        // 这会读取 100 万元素,阻塞 Redis
    }
    
    // 正确:分批读取
    public void goodPractice() {
        long size = jedis.llen("big_list");
        int batchSize = 1000;
        
        for (int start = 0; start < size; start += batchSize) {
            int end = Math.min(start + batchSize - 1, (int) size);
            List<String> batch = jedis.lrange("big_list", start, end);
            process(batch);
        }
    }
    
    // 正确:使用 SCAN
    public void scanPractice() {
        // 不存在,但可以用 LRANGE 分批
    }
}

场景三:大 Hash 操作

java
/**
 * 大 Hash 操作
 */
public class BigHashOperation {
    
    // 错误:一次性读取整个 Hash
    public void badPractice() {
        Map<String, String> all = jedis.hgetAll("big_hash");
        // 假设有 100 万字段,这会阻塞 Redis
    }
    
    // 正确:分批读取
    public void goodPractice() {
        ScanParams params = new ScanParams().match("*").count(100);
        Map<String, String> result = new HashMap<>();
        String cursor = "0";
        
        do {
            ScanResult<Map.Entry<String, String>> result = 
                jedis.hscan("big_hash", cursor, params);
            cursor = result.getCursor();
            
            for (Map.Entry<String, String> entry : result.getResult()) {
                result.put(entry.getKey(), entry.getValue());
            }
            
        } while (!"0".equals(cursor));
    }
}

场景四:SORT 命令

java
/**
 * SORT 命令
 * 
 * 时间复杂度 O(n log n)
 * 对大集合排序会非常慢
 */
public class SortCommand {
    
    // 错误:对大 List 排序
    public void badPractice() {
        // 对有 100 万元素的 List 排序
        List<String> sorted = jedis.sort("big_list");
    }
    
    // 正确:使用 ZSet 代替
    public void goodPractice() {
        // 如果需要排序,使用 ZSet
        // ZSet 天然有序,查询 O(log n)
        // ZRANGEBYSCORE 可以高效获取范围
    }
}

慢命令处理方案

方案一:使用 SCAN 替代 KEYS

java
/**
 * SCAN vs KEYS
 * 
 * KEYS: O(n),阻塞
 * SCAN: O(1) 每次调用,返回一批,返回所有需要多次调用
 */
public class ScanVsKeys {
    
    /**
     * 安全地遍历所有 key
     */
    public void safeScan() {
        ScanParams params = new ScanParams()
            .match("user:*")
            .count(100);  // 每批返回数量
        
        String cursor = "0";
        int processed = 0;
        
        do {
            ScanResult<String> result = jedis.scan(cursor, params);
            cursor = result.getCursor();
            processed += result.getResult().size();
            
            // 处理这一批
            for (String key : result.getResult()) {
                processKey(key);
            }
            
        } while (!"0".equals(cursor));
        
        System.out.println("处理了 " + processed + " 个 key");
    }
    
    /**
     * SCAN 系列命令
     */
    public void scanSeries() {
        // SCAN: 遍历所有 key
        jedis.scan(cursor, params);
        
        // SSCAN: 遍历 Set
        jedis.sscan("big_set", cursor, params);
        
        // HSCAN: 遍历 Hash
        jedis.hscan("big_hash", cursor, params);
        
        // ZSCAN: 遍历 ZSet
        jedis.zscan("big_zset", cursor, params);
    }
}

方案二:大 key 拆分

java
/**
 * 大 key 拆分策略
 */
public class BigKeySplit {
    
    /**
     * 大 String 拆分
     */
    public void splitBigString() {
        String key = "big_data";
        String value = getFromDatabase();  // 假设 10MB
        
        // 拆分为多个小 chunk
        int chunkSize = 100 * 1024;  // 100KB
        int chunkCount = (value.length() + chunkSize - 1) / chunkSize;
        
        // 保存元数据
        jedis.hset(key + ":meta", "chunks", String.valueOf(chunkCount));
        jedis.hset(key + ":meta", "total", String.valueOf(value.length()));
        
        // 保存分片
        for (int i = 0; i < chunkCount; i++) {
            int start = i * chunkSize;
            int end = Math.min(start + chunkSize, value.length());
            String chunk = value.substring(start, end);
            jedis.set(key + ":" + i, chunk);
        }
    }
    
    /**
     * 大 List 拆分
     */
    public void splitBigList() {
        String key = "big_list";
        String metaKey = key + ":meta";
        
        // 扫描原 List(使用 LRANGE 分批)
        int chunkSize = 1000;
        int chunkIndex = 0;
        
        while (true) {
            List<String> batch = jedis.lrange(key, 0, chunkSize - 1);
            if (batch.isEmpty()) break;
            
            // 保存到新 key
            String chunkKey = key + ":" + chunkIndex;
            jedis.rpush(chunkKey, batch.toArray(new String[0]));
            
            // 删除原 List 中的这批
            jedis.ltrim(key, batch.size(), -1);
            
            chunkIndex++;
        }
        
        // 保存元数据
        jedis.hset(metaKey, "chunks", String.valueOf(chunkIndex));
    }
    
    /**
     * 大 Hash 拆分
     */
    public void splitBigHash() {
        String key = "big_hash";
        String metaKey = key + ":meta";
        
        // 分批读取
        ScanParams params = new ScanParams().match("*").count(1000);
        String cursor = "0";
        int chunkIndex = 0;
        
        while (true) {
            ScanResult<Map.Entry<String, String>> result = 
                jedis.hscan(key, cursor, params);
            cursor = result.getCursor();
            
            if (result.getResult().isEmpty()) break;
            
            // 保存到新 Hash
            Map<String, String> chunk = new HashMap<>();
            for (Map.Entry<String, String> entry : result.getResult()) {
                chunk.put(entry.getKey(), entry.getValue());
            }
            
            if (!chunk.isEmpty()) {
                jedis.hset(key + ":" + chunkIndex, chunk);
                chunkIndex++;
            }
            
            if ("0".equals(cursor)) break;
        }
        
        jedis.hset(metaKey, "chunks", String.valueOf(chunkIndex));
    }
}

方案三:异步执行

java
/**
 * 异步执行慢命令
 */
public class AsyncCommand {
    
    private Jedis jedis;
    private ExecutorService executor;
    
    /**
     * 在后台执行 KEYS(会阻塞 Redis,但不影响主线程)
     */
    public Future<List<String>> backgroundKeys(String pattern) {
        return executor.submit(() -> {
            // 这个操作会阻塞 Redis
            // 但不会阻塞调用线程
            return new ArrayList<>(jedis.keys(pattern));
        });
    }
    
    /**
     * 使用 UNLINK 替代 DEL
     */
    public void asyncDelete(String key) {
        // DEL 是同步的,会阻塞
        // UNLINK 是异步的,立即返回
        jedis.unlink(key);  // Redis 4.0+
    }
    
    /**
     * 使用懒删除
     */
    public void lazyDelete(String key) {
        // 设置过期时间,让 Redis 后台删除
        jedis.expire(key, 1);  // 1 秒后过期
    }
}

方案四:监控告警

bash
# redis.conf 配置

# 慢查询日志
slowlog-log-slower-than 1000  # 1毫秒
slowlog-max-len 1000

# 命令监控
monitor-command enabled
yaml
# Prometheus 告警规则
groups:
  - name: redis_slow_command_alerts
    rules:
      - alert: RedisBlockingCommand
        expr: |
          sum(rate(redis_commands_duration_seconds_sum[5m])) by (cmd) > 1
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "检测到慢命令 {{ $labels.cmd }}"

最佳实践总结

场景错误做法正确做法
遍历 keyKEYSSCAN
遍历 SetSMEMBERSSSCAN
遍历 HashHGETALLHSCAN
遍历 ListLRANGE 0 -1分批 LRANGE
删除大 keyDELUNLINK / 懒删除
读取大 valueGET分 chunk

工具推荐

1. redis-cli --bigkeys

bash
# 找出大 key
redis-cli --bigkeys

# 输出示例
# Scanning 5 databases for big keys...
# 
# Big Key details:
#   String key: "cache:data" - 50MB
#   Hash key: "user:1001" - 10MB, 100000 fields
#   List key: "msg:queue" - 100MB, 1000000 items

2. SCAN 系列

bash
# 遍历 key
SCAN cursor MATCH pattern COUNT count

# 遍历 Set
SSCAN set_key cursor MATCH pattern COUNT count

# 遍历 Hash
HSCAN hash_key cursor MATCH pattern COUNT count

# 遍历 ZSet
ZSCAN zset_key cursor MATCH pattern COUNT count

3. MEMORY USAGE

bash
# 查看 key 的内存使用
redis-cli MEMORY USAGE big_key
# (integer) 52428800  # 50MB

总结

慢命令是 Redis 性能杀手:

  • 识别:慢查询日志、INFO stats
  • 原因:O(n) 命令、大 key、操作频繁
  • 解决:SCAN 替代 KEYS、大 key 拆分、异步执行
  • 预防:代码审查、监控告警

留给你的问题

SCAN 命令虽然不会阻塞 Redis,但多次 SCAN 的总耗时可能超过 KEYS。

在什么情况下,SCAN 的总耗时反而比 KEYS 更长?应该如何权衡?

基于 VitePress 构建