一、问题背景:慢查询日志里的"嫌疑犯"

当你打开Redis的慢查询日志,看到这样一条记录:

{
  "time": 1716000000,
  "duration": 5000000,
  "command": "SORT user:scores BY user:score:*->val GET user:info:*->name LIMIT 0 100"
}

500万微秒,也就是整整5秒。对于一个号称"亚毫秒级响应"的缓存系统来说,这绝对是一个需要立刻关注的危险信号。更让人头疼的是,类似的记录里还夹杂着ZRANGEBYSCORE命令:

{
  "time": 1716000001,
  "duration": 8000000,
  "command": "ZRANGEBYSCORE rank:leaderboard 0 100 WITHSCORES LIMIT 0 1000"
}

这两个命令,一个负责排序,一个负责按分数范围查询。它们出现在慢查询日志里,说明你的Redis性能正在退化。而退化的原因,往往归结为两个方向:要么是数据模型和索引设计从一开始就有问题,要么是你之前没注意到的数据量增长已经触碰了某些命令的复杂度边界。

接下来的内容,我会从这两个角度帮你分析,并给出日常调优的具体方案。


二、SORT命令为什么慢

2.1 SORT的工作方式

Redis的SORT命令可以对列表或集合中的元素进行排序。它本身的设计目标是方便开发者对数据做排序操作,但它的工作方式决定了它并不是一个"轻量级"命令。

当你执行一个SORT命令时,Redis内部实际上做了这些事:

  1. 先从列表或集合里把所有元素读出来
  2. 如果指定了BY模式,还会对每个元素做额外的查找,比如去另一个哈希表里查某个字段的值
  3. 如果指定了GET模式,排序完成后还要为每个元素查找对应的显示值
  4. 最后把排序结果返回给你

这里面最耗时的步骤往往是第2步和第3步。比如你的集合里有1000个元素,每个元素都是一个用户ID,你要按照用户在另一个哈希表里的分数排序,那么Redis就要对每个用户ID做一次查找。虽然单次查找很快,但1000次查找加在一起,耗时就很可观了。

2.2 一个典型的SORT慢查询示例

技术栈:Redis 6.2(以单机模式为例)

# 技术栈:Redis 6.2
# 场景:电商商品价格排序,需要按价格升序展示前100个商品

# 第一步:创建商品ID集合,模拟10万个商品
redis-cli SADD product:ids "p_000001" "p_000002" "p_000003"

# 批量写入更多商品ID(实际生产中用脚本循环写入)
for i in $(seq 1 100000); do
  redis-cli SADD product:ids "p_$(printf '%06d' $i)"
done

# 第二步:为每个商品设置价格信息(存储在独立的哈希表中)
for i in $(seq 1 100000); do
  price=$(awk 'BEGIN{srand();print int(rand()*100000)}')
  redis-cli HSET "product:info:p_$(printf '%06d' $i)" price $price name "商品$(printf '%06d' $i)"
done
# 技术栈:Redis 6.2
# 执行一个带BY和GET的SORT命令
# 含义:从product:ids集合中取出所有元素
#       按product:info:{元素}哈希表中price字段的值进行排序
#       排序后返回每个元素在product:info:{元素}中的name字段值
#       只取前100条
redis-cli SORT product:ids BY "product:info:*->price" GET "product:info:*->name" LIMIT 0 100

上面的SORT命令看起来很简单,但实际上Redis需要做大量的内部查找。product:ids集合里有10万个元素,每个元素都要去对应的哈希表里查一次price字段进行排序比较,排序完成后还要再查一次name字段来获取返回值。这就意味着至少20万次哈希表查找操作。

如果你的哈希表key的命名不够规整,或者hash存储了大量冗余字段,查找开销还会进一步增加。

2.3 SORT的复杂度分析

SORT命令的时间复杂度取决于几个因素:

  • 无BY模式时:O(N),N是集合长度,仅做简单排序
  • 有BY模式时:O(N) + 查找开销 × N,每个元素都要做一次跨key查找
  • 有GET模式时:额外增加 查找次数 × 返回数量

如果集合有100万个元素,每个BY查找都涉及一个跨key的哈希表查询,总耗时很容易突破毫秒级,进入慢查询的范畴。


三、ZRANGEBYSCORE为什么慢

3.1 ZRANGEBYSCORE的基本原理

ZRANGEBYSCORE是Redis有序集合的核心查询命令之一。它可以根据分数范围查询有序集合中的元素。正常情况下,这个命令的复杂度是O(log(N) + M),其中N是有序集合的大小,M是返回的元素数量。

也就是说,在大多数情况下,ZRANGEBYSCORE应该是一个比较高效的命令。那它为什么还会出现慢查询呢?

3.2 慢查询的几种典型场景

场景一:范围过大导致返回大量数据

# 技术栈:Redis 6.2
# 场景:用户活跃度排行榜,查询所有活跃用户

# 有序集合里有500万个元素
# 查询分数从0到正无穷的范围内所有元素
# 这个操作会尝试返回500万个元素,即使加了LIMIT也可能很慢
redis-cli ZRANGEBYSCORE rank:active_users 0 +inf WITHSCORES

当你用ZRANGEBYSCORE查询一个非常大的范围时,即使复杂度公式是O(log(N) + M),M(返回的元素数量)如果过大,返回结果集本身就会成为瓶颈。网络传输大结果集也会占用大量带宽和CPU时间。

场景二:对大数据量的有序集合做频繁的范围查询

# 技术栈:Redis 6.2
# 场景:全站游戏排行榜,每秒数千次查询

# 有序集合rank:leaderboard有1000万条记录
# 每次查询都取前1000名
redis-cli ZRANGEBYSCORE rank:leaderboard -inf +inf WITHSCORES LIMIT 0 1000

# 如果这个查询每秒执行500次,单线程的Redis会被严重阻塞
# 因为每个命令都是串行执行的,500 × 2ms = 1000ms,已经超过了1秒

虽然加了LIMIT,但Redis在内部仍然需要从头开始扫描跳表来定位第一个元素。如果分数范围从-inf开始,取前N个还算快;但如果范围设定不当,或者用了WITHSCORES导致额外开销,就可能出现慢查询。

场景三:高并发下的连锁效应

# 技术栈:Redis 6.2
# 场景:秒杀系统中的库存查询

# 假设秒杀场景下有10个热门商品,每个商品都有对应的库存有序集合
# 每个商品每秒被查询1000次

redis-cli ZRANGEBYSCORE inventory:item_001 0 +inf WITHSCORES
redis-cli ZRANGEBYSCORE inventory:item_002 0 +inf WITHSCORES
redis-cli ZRANGEBYSCORE inventory:item_003 0 +inf WITHSCORES
# ... 共10个商品,每个每秒1000次查询

# Redis单线程模型下,10000次/秒的查询请求会严重阻塞其他命令
# 导致整个Redis实例响应变慢,所有业务都受影响

Redis是单线程模型,所有命令都是串行执行的。一个慢查询会阻塞后续的所有命令,这是Redis性能退化的根本原因之一。

3.3 ZRANGEBYSCORE的底层索引机制

Redis的有序集合内部使用两个数据结构来存储数据:跳表和字典。跳表用来按分数排序,支持范围查询;字典用来按成员快速查找,时间复杂度O(1)。

跳表的优势是范围查询效率高,平均查找复杂度是O(log(N))。但在极端情况下——比如有序集合有上千万条记录,且分数分布不均匀——范围查询仍然可能变慢。特别是当你查询的范围覆盖了大量数据点时,跳表的遍历成本会显著增加,因为跳表在返回结果时需要按顺序遍历。

3.4 一个完整的性能对比测试

# 技术栈:Redis 6.2
# 性能对比:不同数据量下ZRANGEBYSCORE的耗时

# 准备测试数据:创建3个不同大小的有序集合
# 小集合:1000条
for i in $(seq 1 1000); do
  redis-cli ZADD "test:small" $i "member_$i"
done

# 中集合:10万条
for i in $(seq 1 100000); do
  redis-cli ZADD "test:medium" $i "member_$i"
done

# 大集合:100万条
for i in $(seq 1 1000000); do
  redis-cli ZADD "test:large" $i "member_$i"
done

# 测试查询:取前100条数据
# 预期耗时:小集合<1ms,中集合1-2ms,大集合5-10ms
redis-cli TIME  # 获取当前时间
redis-cli ZRANGEBYSCORE test:small 0 +inf WITHSCORES LIMIT 0 100
redis-cli TIME

redis-cli TIME
redis-cli ZRANGEBYSCORE test:medium 0 +inf WITHSCORES LIMIT 0 100
redis-cli TIME

redis-cli TIME
redis-cli ZRANGEBYSCORE test:large 0 +inf WITHSCORES LIMIT 0 100
redis-cli TIME

# 测试全量返回(不推荐在生产环境使用)
redis-cli TIME
redis-cli ZRANGEBYSCORE test:large 0 +inf WITHSCORES  # 返回100万条,极慢
redis-cli TIME

四、索引设计还是数据量增长?

4.1 如何判断是索引设计问题

如果SORT和ZRANGEBYSCORE在数据量不大的时候就已经出现在慢查询日志中,那大概率是索引设计——或者说数据模型——的问题。

常见的设计错误包括:

# 技术栈:Redis 6.2
# 错误设计示例

# 错误1:把所有用户数据都存在一个巨大的有序集合里
# 然后每次都做全局排序或全局范围查询
redis-cli ZADD user:scores 100 user_000001
redis-cli ZADD user:scores 200 user_000002
redis-cli ZADD user:scores 150 user_000003
# ... 持续添加百万级数据
# 查询时取前100:看似只取100条,但集合中有百万数据,跳表遍历仍然有开销
redis-cli ZRANGEBYSCORE user:scores -inf +inf WITHSCORES LIMIT 0 100

# 错误2:用SORT实现本可以用ZADD+ZRANGEBYSCORE完成的功能
# SORT会遍历整个集合做排序,即使最后只取前10条
redis-cli SORT user:ids BY "user:score:*->val" LIMIT 0 10

# 正确做法:直接用有序集合
redis-cli ZADD rank:by_score 100 user_000001
redis-cli ZADD rank:by_score 200 user_000002
redis-cli ZRANGEBYSCORE rank:by_score -inf +inf WITHSCORES LIMIT 0 10

# 错误3:BY和GET引用的key设计不合理,每个元素都要跨多个key查找
redis-cli SORT user:ids BY "user:score:*->val" GET "user:name:*->val" GET "user:level:*->val"
# 每个元素要做3次哈希表查找,1000个元素就是3000次查找

4.2 如何判断是数据量增长问题

如果SORT和ZRANGEBYSCORE在数据量较小的时候性能正常,但随着数据量增长逐渐变慢,那说明数据量已经触碰到了命令的复杂度边界。

你可以通过下面的命令来辅助判断:

# 技术栈:Redis 6.2
# 诊断工具集合

# 查看有序集合的元素数量
redis-cli ZCARD rank:leaderboard

# 查看慢查询日志,筛选出ZRANGEBYSCORE相关记录
redis-cli SLOWLOG GET 128

# 查看数据库中最大的key(Redis 4.0+支持)
redis-cli --bigkeys

# 实时监控命令执行情况
redis-cli MONITOR | grep -E "SORT|ZRANGEBYSCORE"

# 查看命令统计信息,了解每个命令的平均耗时
redis-cli INFO commandstats

# 查看有序集合的内存使用
redis-cli MEMORY USAGE rank:leaderboard

4.3 两种问题的解决方案对比

问题类型 典型表现 解决方向
索引设计缺失 数据量小也慢 优化数据结构、减少跨key查找、用ZADD替代SORT
数据量增长 数据量小不慢,大了才慢 分片、过期策略、二级索引、查询范围收窄

五、日常调优从哪入手

5.1 第一步:开启慢查询监控

# 技术栈:Redis 6.2
# 调优前必须先建立监控基础

# 设置慢查询阈值为10毫秒(生产环境建议设为5-10毫秒)
redis-cli CONFIG SET slowlog-log-slower-than 10000

# 慢查询日志最多保留500条记录
redis-cli CONFIG SET slowlog-max-len 500

# 查看当前慢查询
redis-cli SLOWLOG GET 50

# 清空慢查询日志(调优前建议清空,方便观察变化)
redis-cli SLOWLOG RESET

5.2 第二步:优化SORT的使用方式

方案一:尽量用有序集合替代SORT

# 技术栈:Redis 6.2
# 推荐做法:用ZADD代替SORT+BY+GET的组合操作
# 直接在写入时维护排序关系

# 添加数据时直接按分数排序写入有序集合
redis-cli ZADD rank:daily 85.5 "user_000001"
redis-cli ZADD rank:daily 92.3 "user_000002"
redis-cli ZADD rank:daily 78.1 "user_000003"

# 查询时直接按分数范围取,性能远好于SORT
redis-cli ZRANGEBYSCORE rank:daily 0 +inf WITHSCORES LIMIT 0 100

# 反例:不要这样做(每次查询都排序)
# 即使只取前10条,SORT也要遍历整个集合完成排序
redis-cli SORT user:ids BY "user:score:*->val" GET "user:name:*->val" LIMIT 0 10

方案二:对SORT命令加合理的LIMIT

# 技术栈:Redis 6.2
# 优化前:返回全量排序结果(极度危险,不要这样做)
redis-cli SORT user:ids BY "user:score:*->val"

# 优化后:始终加上LIMIT,避免返回全量排序结果
redis-cli SORT user:ids BY "user:score:*->val" GET "user:profile:*->name" LIMIT 0 50

# 如果只需要排序结果存储到另一个key,使用STORE参数
# 这样不会直接返回大量数据给客户端
redis-cli SORT user:ids BY "user:score:*->val" STORE sorted:temp LIMIT 0 1000

# 后续查询sorted:temp时,有序集合已经排好序,查询非常快
redis-cli ZRANGEBYSCORE sorted:temp 0 +inf WITHSCORES LIMIT 0 50

方案三:减少SORT中的跨key查找

# 技术栈:Redis 6.2
# 优化前:每个元素都要查3个不同的key
# 效率极低,一次SORT要执行3N次哈希表查找
redis-cli SORT user:ids BY "user:score:*->val" GET "user:name:*->val" GET "user:level:*->val"

# 优化后:把需要的字段合并到一个哈希表里
# 一次SORT只需要做2N次哈希表查找(BY查1次 + GET查1次)
redis-cli HSET user:info:user_001 score 95.5 name "张三" level 5
redis-cli HSET user:info:user_002 score 88.2 name "李四" level 3

redis-cli SORT user:ids BY "user:info:*->score" GET "user:info:*->name" LIMIT 0 50

5.3 第三步:优化ZRANGEBYSCORE的使用方式

方案一:收窄查询范围

# 技术栈:Redis 6.2
# 优化前:查询全部分数范围
redis-cli ZRANGEBYSCORE rank:global -inf +inf WITHSCORES LIMIT 0 100

# 优化后:根据业务需要设置合理的分数边界
# 比如只查最近7天的活跃用户(假设分数是时间戳)
redis-cli ZRANGEBYSCORE rank:active 1715000000 1716000000 WITHSCORES LIMIT 0 100

# 再比如只查分数在80到100之间的用户
redis-cli ZRANGEBYSCORE rank:score 80 100 WITHSCORES LIMIT 0 100

方案二:利用LIMIT做分页查询

# 技术栈:Redis 6.2
# 使用LIMIT实现分页,每次只取需要的数量
# 第一页(0-99)
redis-cli ZRANGEBYSCORE rank:leaderboard 0 +inf WITHSCORES LIMIT 0 100

# 第二页(100-199)
redis-cli ZRANGEBYSCORE rank:leaderboard 0 +inf WITHSCORES LIMIT 100 100

# 第三页(200-299)
redis-cli ZRANGEBYSCORE rank:leaderboard 0 +inf WITHSCORES LIMIT 200 100

# 注意:越深的分页,性能越差
# 因为Redis需要从头遍历到offset位置才开始返回
# 生产环境中建议分页深度不超过20页

方案三:按时间窗口切分有序集合

# 技术栈:Redis 6.2
# 不要把所有数据放在一个有序集合里
# 按天或按业务维度切分,每个子集数据量可控

# 每日排行榜(数据量小,查询快)
redis-cli ZADD rank:daily:20250519 95.5 user_000001
redis-cli ZRANGEBYSCORE rank:daily:20250519 0 +inf WITHSCORES LIMIT 0 50

# 周排行榜
redis-cli ZADD rank:weekly:2025W20 98.2 user_000002
redis-cli ZRANGEBYSCORE rank:weekly:2025W20 0 +inf WITHSCORES LIMIT 0 50

# 月排行榜
redis-cli ZADD rank:monthly:202505 99.1 user_000003
redis-cli ZRANGEBYSCORE rank:monthly:202505 0 +inf WITHSCORES LIMIT 0 50

# 如果业务需要跨时间范围的查询,用Lua脚本合并结果
redis-cli EVAL "
  local results = {}
  local dates = cjson.decode(ARGV[1])
  for i, d in ipairs(dates) do
    local data = redis.call('ZRANGEBYSCORE', 'rank:daily:'..d, '0', '+inf', 'WITHSCORES', 'LIMIT', '0', '10')
    for j, v in ipairs(data) do
      table.insert(results, v)
    end
  end
  table.sort(results, function(a,b) return tonumber(b[2]) > tonumber(a[2]) end)
  return cjson.encode(results)
" 0 '["20250519","20250518","20250517"]'

5.4 第四步:设置数据过期策略

# 技术栈:Redis 6.2
# 对临时性数据设置合理的过期时间,避免内存无限增长

# 每日排行榜设置24小时过期
redis-cli EXPIRE rank:daily:20250519 86400

# 周排行榜设置7天过期
redis-cli EXPIRE rank:weekly:2025W20 604800

# 月排行榜设置30天过期
redis-cli EXPIRE rank:monthly:202505 2592000

# 使用Redis 6.2+的主动过期策略
# active-expire-effort控制每次周期中检查过期的key数量
redis-cli CONFIG SET active-expire-effort 10

# 监控key的存活情况
redis-cli TTL rank:daily:20250519
redis-cli PTTL rank:weekly:2025W20

# 设置全局内存策略,当内存达到上限时自动淘汰
redis-cli CONFIG SET maxmemory-policy allkeys-lru

5.5 第五步:使用Lua脚本优化批量操作

# 技术栈:Redis 6.2
# 用Lua脚本减少网络往返,提升查询效率

# 示例:批量获取多个用户的排行榜数据
# 如果不用Lua,需要4000次网络请求;用Lua只需1次
redis-cli EVAL "
  local users = cjson.decode(ARGV[1])
  local results = {}
  for i, user in ipairs(users) do
    local rank = redis.call('ZRANK', 'rank:leaderboard', user)
    local score = redis.call('ZSCORE', 'rank:leaderboard', user)
    table.insert(results, {user, rank, score})
  end
  return cjson.encode(results)
" 0 '["user_001","user_002","user_003","user_004"]'

# 示例:安全地获取并删除已处理的延迟任务
redis-cli EVAL "
  local current_time = tonumber(ARGV[1])
  local count = tonumber(ARGV[2])
  local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], '0', current_time, 'LIMIT', '0', count)
  if #tasks > 0 then
    redis.call('ZREM', KEYS[1], unpack(tasks))
  end
  return tasks
" 1 delay_queue $(date +%s) 50

5.6 第六步:使用渐进式扫描替代全量查询

# 技术栈:Redis 6.2
# 如果需要遍历大量有序集合数据,用SCAN模式逐步获取
# 而不是用ZRANGEBYSCORE一次取全部

# Lua脚本示例:分批查询有序集合数据
redis-cli EVAL "
  local offset = tonumber(ARGV[1])
  local limit = tonumber(ARGV[2])
  local min = ARGV[3]
  local max = ARGV[4]
  return redis.call('ZRANGEBYSCORE', KEYS[1], min, max, 'WITHSCORES', 'LIMIT', offset, limit)
" 1 rank:leaderboard 0 1000 -inf +inf

# 示例:用ZRANGEBYRANK替代大范围的ZRANGEBYSCORE
# 当你只需要按排名取数据时,ZRANGEBYRANK更快
redis-cli ZRANGEBYRANK rank:leaderboard 0 99 REV

六、应用场景分析

6.1 排行榜场景

排行榜是SORT和ZRANGEBYSCORE最常见的应用场景。游戏排行榜、电商销量排行、社交媒体热度榜等都需要这类能力。

在这个场景下,推荐做法是:

# 技术栈:Redis 6.2
# 用ZADD维护排行榜,用ZRANGEBYSCORE查询排行榜
# 这是最标准、最高效的用法

# 更新分数(会自动排序,无需额外排序操作)
redis-cli ZADD game:leaderboard 15000 player_001
redis-cli ZADD game:leaderboard 22000 player_002
redis-cli ZADD game:leaderboard 18500 player_003

# 查询前10名(按分数从高到低)
redis-cli ZRANGEBYSCORE game:leaderboard -inf +inf REV WITHSCORES LIMIT 0 10

# 查询某玩家的排名
redis-cli ZRANK game:leaderboard player_001

# 查询某玩家当前分数
redis-cli ZSCORE game:leaderboard player_001

# 实时增加分数(累加模式)
redis-cli ZINCRBY game:leaderboard 500 player_001

关键点在于:排行榜场景下,不要用SORT来实现排序,直接用有序集合的ZADD和ZRANGEBYSCORE就够了。SORT在排行榜场景下几乎没有任何优势,反而更慢。

6.2 延迟队列场景

延迟队列通常用有序集合实现,以时间戳作为分数:

# 技术栈:Redis 6.2
# 场景:订单超时未支付自动取消

# 添加延迟任务(分数是计划执行时间的时间戳)
redis-cli ZADD delay_queue 1716003600 "order_timeout:order_001"
redis-cli ZADD delay_queue 1716003660 "order_timeout:order_002"
redis-cli ZADD delay_queue 1716003720 "order_timeout:order_003"

# 获取已到期的任务(查询分数小于当前时间的任务)
current_time=$(date +%s)
redis-cli ZRANGEBYSCORE delay_queue 0 $current_time LIMIT 0 100

# 更优的做法:原子性地查询并弹出已到期任务
redis-cli EVAL "
  local now = tonumber(ARGV[1])
  local count = tonumber(ARGV[2])
  local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], '0', now, 'LIMIT', '0', count)
  if #tasks > 0 then
    redis.call('ZREM', KEYS[1], unpack(tasks))
  end
  return tasks
" 1 delay_queue $current_time 100

6.3 多维度排序场景

如果业务需求涉及多维度排序,SORT命令确实有其优势,但需要注意性能:

# 技术栈:Redis 6.2
# 场景:文章列表需要按热度排序,同时展示标题和作者

# 数据准备:把排序依据和展示数据合并到同一个哈希表
redis-cli HSET post:info:1001 hot_score 9500 title "Redis性能优化指南" author "张三"
redis-cli HSET post:info:1002 hot_score 8200 title "Lua脚本编程实战" author "李四"
redis-cli HSET post:info:1003 hot_score 7100 title "Redis集群部署" author "王五"

# 使用SORT按热度排序,一次取回标题和作者
redis-cli SORT post:ids BY "post:info:*->hot_score" GET "post:info:*->title" GET "post:info:*->author" GET "#" LIMIT 0 50

如果数据量不大(少于几万条),SORT还是可以接受的。但数据量超过10万条时,建议改用有序集合加Lua脚本的组合方案。

6.4 实时消息推送场景

# 技术栈:Redis 6.2
# 场景:在线用户的实时状态推送,需要按最后活跃时间排序

# 用户上线或活跃时,更新有序集合中的分数(时间戳)
redis-cli ZADD online_users $(date +%s%N) user_001
redis-cli ZADD online_users $(date +%s%N) user_002

# 获取最近活跃的100个用户
redis-cli ZRANGEBYSCORE online_users -inf +inf REV WITHSCORES LIMIT 0 100

# 清理超过30分钟未活跃的用户(假设分数是毫秒级时间戳)
now=$(date +%s%N)
threshold=$(echo "$now - 1800000000" | bc)
redis-cli ZRANGEBYSCORE online_users -inf $threshold WITHSCORES
# 获取到后批量删除

七、技术优缺点

7.1 SORT命令

优点:

  • 灵活性高,可以按任意字段的值排序,不需要预先设计索引
  • 支持BY和GET组合,可以在一次命令中完成排序和数据映射
  • 可以对排序结果直接STORE到另一个key,方便后续查询
  • 支持lexicographic和numeric两种排序模式

缺点:

  • 不支持部分排序优化——即使只要前N条,也会遍历全部数据完成排序
  • 跨key查找开销大,数据量大时性能急剧下降
  • 不支持自定义排序逻辑,只能用数值或字典序排序
  • 排序是内存操作,大量数据会导致内存压力和阻塞
  • 不支持增量更新——每次修改数据后都要重新排序

7.2 ZRANGEBYSCORE命令

优点:

  • 基于跳表实现,范围查询效率高,平均O(log(N))
  • 支持WITHSCORES、REV、LIMIT等灵活参数
  • 可以作为原子操作的一部分使用,配合Lua脚本无锁操作
  • 与ZADD、ZINCRBY、ZCOUNT等命令配合使用非常方便
  • 天然支持按分数排序,无需额外排序操作

缺点:

  • 返回大量数据时会占用大量带宽和内存
  • 对超大有序集合(千万级)的极端范围查询仍然可能变慢
  • 分数类型必须是浮点数,某些整数精度场景下可能丢失精度
  • 不支持二级索引,多维查询需要额外设计
  • 越深的分页查询性能越差(LIMIT offset大的时候)

八、注意事项

8.1 监控先行

在优化之前,务必先建立完整的监控体系:

# 技术栈:Redis 6.2
# 建立全方位的Redis性能监控

# 设置性能日志(Redis 6+支持)
redis-cli CONFIG SET latency-monitor-threshold 100

# 使用INFO命令查看性能指标
redis-cli INFO commandstats
# 关注used_memory、keyspace_hits、keyspace_misses等指标

# 关注latency统计
redis-cli LATENCY LATEST
redis-cli LATENCY DOCTOR

# 设置key过期策略,避免内存被旧数据占满
redis-cli CONFIG SET maxmemory-policy allkeys-lru

# 设置最大内存限制(根据实际可用内存调整)
redis-cli CONFIG SET maxmemory 4gb

# 定期清理慢查询日志,关注新增记录
redis-cli SLOWLOG GET 20

8.2 分片策略

当单个Redis实例的数据量超过千万级时,考虑使用Redis Cluster进行分片:

# 技术栈:Redis 6.2
# Redis Cluster自动分片,将数据分散到多个节点

# 对于有序集合类数据,可以通过hash tag实现同分片查询
# {rank}是hash tag,确保所有相关key在同一个slot
redis-cli ZADD "{rank}:daily" 100 user_001
redis-cli ZADD "{rank}:daily" 200 user_002
redis-cli ZADD "{rank}:weekly" 300 user_001

# 监控每个节点的数据分布
redis-cli CLUSTER NODES

# 查看某个key所在的slot
redis-cli CLUSTER KEYSLOT "rank:daily"

8.3 避免全量扫描

# 技术栈:Redis 6.2
# 错误做法:全量查询有序集合
redis-cli ZRANGEBYSCORE rank:global -inf +inf WITHSCORES

# 正确做法:按业务需求限定范围
redis-cli ZRANGEBYSCORE rank:global -inf +inf WITHSCORES LIMIT 0 100

# 如果需要遍历所有key,使用SCAN模式分批获取
redis-cli EVAL "
  local cursor = '0'
  local batch_size = 100
  local all_keys = {}
  while true do
    local result = redis.call('SCAN', cursor, 'COUNT', batch_size)
    cursor = result[1]
    for i, key in ipairs(result[2]) do
      table.insert(all_keys, key)
    end
    if cursor == '0' then break end
  end
  return #all_keys
" 0

# 如果需要清理大量过期key,使用UNLINK代替DEL
# UNLINK异步删除,不会阻塞主线程
redis-cli EVAL "
  local cursor = '0'
  while true do
    local result = redis.call('SCAN', cursor, 'MATCH', 'expired:*', 'COUNT', 100)
    cursor = result[1]
    for i, key in ipairs(result[2]) do
      redis.call('UNLINK', key)
    end
    if cursor == '0' then break end
  end
  return 'cleanup done'
" 0

8.4 内存管理

# 技术栈:Redis 6.2
# 有序集合的内存优化

# 查看有序集合的内存使用情况
redis-cli MEMORY USAGE rank:leaderboard

# 启用内存优化(Redis 6+)
# 小集合会使用嵌入式存储(listpack),节省内存
redis-cli CONFIG GET ziplist-max-entries
redis-cli CONFIG GET hash-max-ziplist-entries

# 监控内存碎片率
redis-cli INFO memory
# 关注mem_fragmentation_ratio指标
# 如果超过1.5,考虑执行MEMORY PURGE
redis-cli MEMORY PURGE

# 统计有序集合中不同分数的元素数量
redis-cli ZCOUNT rank:leaderboard -inf +inf
redis-cli ZCOUNT rank:leaderboard 0 50
redis-cli ZCOUNT rank:leaderboard 50 100

8.5 生产环境检查清单

# 技术栈:Redis 6.2
# 生产环境上线前的检查清单

# 1. 确认慢查询阈值设置合理
redis-cli CONFIG GET slowlog-log-slower-than

# 2. 确认最大内存和淘汰策略
redis-cli CONFIG GET maxmemory
redis-cli CONFIG GET maxmemory-policy

# 3. 确认持久化配置(避免数据丢失)
redis-cli CONFIG GET save
redis-cli CONFIG GET appendonly

# 4. 确认大key不存在(用--bigkeys检测)
redis-cli --bigkeys

# 5. 确认主从复制正常
redis-cli INFO replication

# 6. 确认内存使用在安全范围内
redis-cli INFO memory | grep used_memory

# 7. 模拟压力测试
redis-cli --intrinsic-latency 1000
redis-cli --latency-history

九、文章总结

Redis慢查询日志里出现SORT和ZRANGEBYSCORE等高耗时命令,本质上只有两个原因:要么是你的数据模型设计没有考虑到排序操作的复杂度开销,要么是你的数据量增长已经超出了这些命令能高效处理的范围。

判断是哪种原因,有一个简单的方法:如果数据量小的时候就已经慢,那就是设计问题;如果数据量小的时候正常、大了之后才慢,那就是增长问题。

日常调优的核心思路总结为以下几点:

第一,能用有序集合就别用SORT。有序集合的ZADD和ZRANGEBYSCORE组合在绝大多数场景下都比SORT更高效。SORT的唯一优势是灵活性,但代价是性能。

第二,给所有范围查询加LIMIT。避免不必要的网络传输和内存占用,LIMIT是ZRANGEBYSCORE的生命线。

第三,按业务维度切分数据。用多个小的有序集合替代一个巨大的有序集合,每个子集的数据量控制在十万级以内。

第四,设置合理的过期策略。让旧数据自动清理,控制数据总量,防止有序集合无限膨胀。

第五,建立监控体系。慢查询日志、INFO commandstats、LATENCY命令,三者缺一不可。只有持续监控,才能及时发现性能退化。

第六,善用Lua脚本。把多次网络请求合并为一次,减少网络往返开销,同时保证操作的原子性。

记住,Redis是一个内存数据库,它的性能优势建立在数据量少、操作简单的假设之上。当你的数据量和查询复杂度超过这个假设时,就需要调整架构来适应,而不是指望Redis"扛住"一切。理解SORT和ZRANGEBYSCORE的复杂度边界,在设计和开发阶段就规避这些边界,才能让你的Redis始终保持在高性能状态。