Redis面试精讲 Day 24:Redis实现限流、计数与排行榜
【Redis面试精讲 Day 24】Redis实现限流、计数与排行榜
在“Redis面试精讲”系列的第24天,我们将深入探讨Redis实现限流、计数与排行榜三大高频业务场景。作为“Redis应用实战”阶段的核心内容,本日主题覆盖了几乎所有互联网公司在高并发系统设计中必须面对的技术挑战。该主题是后端开发、架构师岗位面试中的必考题,面试官通过此类问题考察候选人是否具备将Redis数据结构灵活应用于实际业务的能力。本文将从概念解析、原理剖析、多语言代码实现(Java/Python/Go)、高频面试题、生产案例等多个维度全面展开,深入讲解Redis如何利用INCR
、ZSET
、Lua脚本等特性高效实现分布式限流、实时计数和动态排行榜功能,帮助你构建完整的工程化解决方案,从容应对各类复杂场景。
一、概念解析
1. 限流(Rate Limiting)
指在单位时间内限制请求次数,防止系统被突发流量压垮。常见于API接口、登录尝试、抢购活动等场景。
2. 计数(Counting)
对事件发生次数进行统计,如页面浏览量(PV)、用户点击量、订单生成数等。要求高并发下准确、高效。
3. 排行榜(Leaderboard)
基于某个指标(如积分、销售额、活跃度)对用户进行排名展示,要求实时更新、支持分页查询。
功能 | 核心需求 | Redis适用数据结构 |
---|---|---|
限流 | 原子性、时间窗口控制 | String(配合EXPIRE) |
计数 | 高并发写入、累加 | String、Hash |
排行榜 | 排序、范围查询、更新效率 | Sorted Set(ZSET) |
二、原理剖析
1. 限流实现原理
固定窗口限流(Fixed Window)
- 使用
INCR key
实现计数,EXPIRE key 60
设置60秒过期。 - 每次请求前检查当前计数是否超过阈值。
- 缺点:存在“窗口临界点”问题,可能导致两倍流量通过。
滑动窗口限流(Sliding Window)
- 基于Sorted Set记录每次请求的时间戳。
- 每次请求时删除过期时间戳,统计剩余数量。
- 更精确,但内存占用高。
令牌桶/漏桶算法
- 可通过Lua脚本在Redis中实现。
- 令牌桶允许突发流量,漏桶则平滑输出。
2. 计数原理
利用Redis的INCR
、INCRBY
命令实现原子自增,避免并发写冲突。适用于高并发场景下的实时统计。
3. 排行榜原理
使用Sorted Set
,其中:
member
:用户IDscore
:积分/销售额等排名依据- 支持
ZADD
、ZREVRANGE
、ZSCORE
、ZRank
等高效操作
类比:就像班级成绩单,按分数排序,随时可查第几名。
三、代码实现
1. Java(Spring Data Redis)
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;import java.time.Duration;
import java.util.Set;@Service
public class RedisFeaturesService {@Autowired
private StringRedisTemplate redisTemplate;/**
* 固定窗口限流:每分钟最多100次请求
*/
public boolean isAllowed(String userId, String actionKey, int limit, int windowSeconds) {
String key = "rate_limit:" + actionKey + ":" + userId;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
// 首次调用,设置过期时间
redisTemplate.expire(key, Duration.ofSeconds(windowSeconds));
}
return count <= limit;
}/**
* 累计计数:如页面浏览量
*/
public long incrementCounter(String counterKey) {
return redisTemplate.opsForValue().increment(counterKey);
}/**
* 获取当前计数值
*/
public Long getCounter(String counterKey) {
String value = redisTemplate.opsForValue().get(counterKey);
return value == null ? 0L : Long.parseLong(value);
}/**
* 排行榜:添加用户分数
*/
public void addToLeaderboard(String boardKey, String userId, double score) {
redisTemplate.opsForZSet().add(boardKey, userId, score);
}/**
* 获取排行榜前N名(降序)
*/
public Set<String> getTopN(String boardKey, int n) {
return redisTemplate.opsForZSet().reverseRange(boardKey, 0, n - 1);
}/**
* 获取用户排名(从1开始)
*/
public Long getUserRank(String boardKey, String userId) {
Long rank = redisTemplate.opsForZSet().reverseRank(boardKey, userId);
return rank == null ? null : rank + 1;
}/**
* 获取用户分数
*/
public Double getUserScore(String boardKey, String userId) {
return redisTemplate.opsForZSet().score(boardKey, userId);
}
}
2. Python(redis-py)
import redis
import time
from typing import List, Optionalr = redis.Redis(host='localhost', port=6379, db=0)def is_allowed_fixed_window(user_id: str, action: str, limit: int, window: int) -> bool:
"""固定窗口限流"""
key = f"rate_limit:{action}:{user_id}"
current = r.incr(key)
if current == 1:
r.expire(key, window)
return current <= limitdef increment_counter(counter_key: str) -> int:
"""原子自增计数"""
return r.incr(counter_key)def get_counter(counter_key: str) -> int:
"""获取计数值"""
val = r.get(counter_key)
return int(val) if val else 0def add_to_leaderboard(board_key: str, user_id: str, score: float):
"""添加用户到排行榜"""
r.zadd(board_key, {user_id: score})def get_top_n(board_key: str, n: int) -> List[str]:
"""获取前N名"""
return r.zrevrange(board_key, 0, n-1, withscores=False)def get_user_rank(board_key: str, user_id: str) -> Optional[int]:
"""获取用户排名"""
rank = r.zrevrank(board_key, user_id)
return rank + 1 if rank is not None else Nonedef get_user_score(board_key: str, user_id: str) -> float:
"""获取用户分数"""
score = r.zscore(board_key, user_id)
return score if score else 0.0
3. Go(go-redis)
package mainimport (
"context"
"fmt"
"github.com/go-redis/redis/v8"
"strconv"
)var rdb *redis.Client
var ctx = context.Background()func isAllowed(action, userID string, limit, window int) bool {
key := fmt.Sprintf("rate_limit:%s:%s", action, userID)
count, err := rdb.Incr(ctx, key).Result()
if err != nil {
return false
}
if count == 1 {
rdb.Expire(ctx, key, time.Duration(window)*time.Second)
}
return count <= int64(limit)
}func incrementCounter(key string) (int64, error) {
return rdb.Incr(ctx, key).Result()
}func getCounter(key string) (int64, error) {
val, err := rdb.Get(ctx, key).Result()
if err != nil {
return 0, nil
}
return strconv.ParseInt(val, 10, 64)
}func addToLeaderboard(boardKey, userID string, score float64) error {
_, err := rdb.ZAdd(ctx, boardKey, &redis.Z{
Score: score,
Member: userID,
}).Result()
return err
}func getTopN(boardKey string, n int) ([]string, error) {
return rdb.ZRevRange(ctx, boardKey, 0, int64(n-1)).Result()
}func getUserRank(boardKey, userID string) (int64, error) {
rank, err := rdb.ZRevRank(ctx, boardKey, userID).Result()
if err != nil {
return 0, err
}
return rank + 1, nil
}
常见错误及规避
错误 | 风险 | 正确做法 |
---|---|---|
未设置过期时间 | 内存泄漏,计数永久累积 | 所有限流/计数key必须设置TTL |
使用GET+SET实现计数 | 并发下数据不一致 | 使用INCR 原子操作 |
排行榜未分页 | 一次性加载大量数据 | 使用ZREVRANGE start end 分页 |
限流未考虑分布式 | 单节点限制无效 | 所有服务共享Redis状态 |
四、面试题解析
面试题1:如何用Redis实现接口限流?有哪些方案?
考察意图:测试对高并发防护机制的理解。
标准回答模板:
我常用固定窗口限流:使用
INCR
命令对rate_limit:api:/user/info:user123
这样的key进行自增,并通过EXPIRE
设置时间窗口(如60秒)。首次调用时设置过期时间,后续检查返回值是否超过阈值。优点是简单高效,适合大多数场景。对于更精确的需求,可采用滑动窗口,基于Sorted Set记录请求时间戳,删除过期记录后统计数量。还可通过Lua脚本实现令牌桶算法,保证原子性。
面试题2:Redis如何保证计数的准确性?在高并发下会不会出错?
考察意图:测试对Redis原子操作的理解。
标准回答模板:
Redis的
INCR
、INCRBY
等命令是单线程原子操作,即使在高并发下也能保证计数准确。这是由Redis的单线程事件循环模型决定的,所有命令串行执行,不会出现并发写冲突。相比之下,数据库的UPDATE table SET count = count + 1
需要加锁或事务才能保证一致性,性能更低。因此,Redis非常适合高并发计数场景。
面试题3:如何实现一个实时排行榜?如何优化性能?
考察意图:测试对Sorted Set的掌握程度。
标准回答模板:
使用Redis的Sorted Set(ZSET) 实现排行榜:
ZADD board_key score user_id
添加或更新用户分数ZREVRANGE board_key 0 9
获取前10名ZREVRANK board_key user_id
获取用户排名
性能优化建议:
- 设置最大榜单容量,定期清理低分用户;
- 分页查询,避免一次性拉取全部数据;
- 对于超大榜单,可按时间或区域分片;
- 缓存热门排名,减少直接查询。
面试题4:固定窗口限流有什么缺陷?如何改进?
考察意图:测试对算法边界情况的思考。
标准回答模板:
固定窗口限流的主要缺陷是临界点问题:例如在0:59秒有100次请求,1:00秒又有100次,虽然每分钟都未超限,但1分钟内实际通过了200次请求。改进方案有:
- 滑动窗口:基于Sorted Set记录时间戳,精确统计任意60秒内的请求数;
- 令牌桶算法:通过Lua脚本实现,动态生成令牌,支持突发流量;
- 漏桶算法:匀速处理请求,适合限速场景。
生产中可根据需求选择,固定窗口适合简单场景,滑动窗口适合高精度要求。
五、实践案例
案例1:短视频平台点赞计数
某短视频平台每条视频需显示点赞数,日均点赞请求超亿次。
挑战:数据库直接更新性能瓶颈严重。
解决方案:
- 用户点赞时调用
INCR video:123:likes
- 每小时将Redis计数同步到MySQL一次
- 设置
EXPIRE
防止异常累积 - 引入本地缓存减少Redis压力
效果:点赞接口RT从50ms降至5ms,系统吞吐提升10倍。
案例2:电商平台秒杀排行榜
大促期间需实时展示“Top 100抢购用户”。
实现方案:
- 用户成功下单后调用
ZADD seckill:leaderboard 1 user_456
- 前端每5秒调用
ZREVRANGE seckill:leaderboard 0 99
刷新 - 使用独立Redis实例避免影响主业务
- 排行榜过期时间设置为活动结束时间
结果:排行榜实时性高,系统稳定无卡顿。
六、技术对比
功能 | Redis方案 | 替代方案 | 对比优势 |
---|---|---|---|
限流 | INCR + EXPIRE | Nginx限流、Sentinel | 分布式统一控制,灵活 |
计数 | INCR原子操作 | 数据库UPDATE | 高并发下性能更好 |
排行榜 | ZSET排序 | 数据库ORDER BY | 查询更快,支持动态更新 |
对比Memcached:Memcached也支持原子计数,但不支持排序功能,无法实现排行榜。Redis在结构化数据处理上优势明显。
七、面试答题模板
当被问及“如何用Redis实现XX功能?”时,可按以下结构回答:
- 明确需求:确认是限流、计数还是排序类需求。
- 选择数据结构:String用于计数,ZSET用于排序。
- 核心命令:列出
INCR
、ZADD
、ZREVRANGE
等关键命令。 - 原子性保障:强调Redis单线程模型保证操作原子性。
- 边界处理:提及TTL、分页、过期清理等细节。
- 性能优化:建议分片、缓存、异步持久化等。
八、总结
今天我们系统学习了Redis实现限流、计数与排行榜三大实战功能。关键要点包括:
- 限流:使用
INCR
+EXPIRE
实现固定窗口,注意临界点问题。 - 计数:
INCR
是高并发计数的最佳选择,原子且高效。 - 排行榜:
Sorted Set
提供完整的排序、查询、排名功能。 - 所有操作必须设置TTL,防止内存泄漏。
- 复杂场景可结合Lua脚本保证原子性。
明天我们将进入“Redis应用实战”的最后一篇:Redis实现分布式Session与购物车,讲解如何利用Redis构建无状态、高可用的用户会话与购物车系统,敬请期待!
进阶学习资源
- Redis官方文档 - Strings
- Redis Sorted Sets Documentation
- 《Redis实战》——Josiah L. Carlson 著
面试官喜欢的回答要点
- 能准确说出
INCR
、ZSET
等命令的适用场景。 - 理解Redis单线程模型对原子性的保障。
- 提到限流的临界点问题及改进方案。
- 能结合TTL、分页等生产级优化措施。
- 区分固定窗口与滑动窗口的适用场景。
- 强调Lua脚本在复杂逻辑中的作用。
文章标签:Redis, 限流, 计数, 排行榜, ZSET, INCR, 面试, 高并发, 分布式系统, Sorted Set
文章简述:
本文深入解析Redis如何实现限流、计数与排行榜三大高频业务场景。通过Java、Python、Go三语言代码实战,详解INCR原子计数、ZSET实时排序、固定窗口限流等核心技术。剖析面试官常考的临界点问题、性能优化策略,提供生产级解决方案。适用于中高级后端开发者备战分布式系统面试,掌握Redis在高并发场景下的工程化应用能力。