Redis 凭借其单线程命令处理、原子操作和丰富的数据结构,成为实现分布式锁、接口限流、计数器等场景的首选组件。本节将从真实开发需求出发,给出可直接使用的实现方案和注意事项。
14.3.1 分布式锁
1. 为什么需要分布式锁
在单机环境下,使用 synchronized 或 ReentrantLock 即可控制并发。但在多实例微服务环境中,多个服务实例可能同时处理同一条数据(例如扣减库存、防止重复创建订单),这时需要一个跨进程的同步机制——分布式锁。
2. 基于 Redis 的实现原理
利用 SET key value NX EX seconds 命令的原子性:只有当 key 不存在时才设置成功,并同时设置超时时间,防止死锁。释放锁时,必须校验 value(通常使用唯一标识),避免误删其他客户端的锁。
3. 核心代码实现
以下是一个基于 Spring Data Redis 的分布式锁工具类,兼顾获取锁、可重入和自动续期(看门狗机制)的简化版实现:
@Component
public class RedisDistributedLock {
private final StringRedisTemplate redisTemplate;
// 存储线程对应的锁标识,用于可重入和释放时验证
private final ThreadLocal<Map<String, String>> lockKeys =
ThreadLocal.withInitial(HashMap::new);
public RedisDistributedLock(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 尝试获取锁
* @param lockKey 锁的键
* @param expireSeconds 自动过期时间(秒),避免死锁
* @return 是否成功获取
*/
public boolean tryLock(String lockKey, long expireSeconds) {
String lockValue = UUID.randomUUID().toString();
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofSeconds(expireSeconds));
if (Boolean.TRUE.equals(success)) {
lockKeys.get().put(lockKey, lockValue);
return true;
}
return false;
}
/**
* 可重入加锁(同一线程可重复获取),简单版本:本地记录重入次数
* 更严谨的生产级实现可基于 Redis Hash 记录线程重入计数
*/
public boolean tryLockReentrant(String lockKey, long expireSeconds) {
Map<String, String> keys = lockKeys.get();
// 当前线程已持有锁,直接重入计数+1
if (keys.containsKey(lockKey)) {
return true; // 实际应该维护计数,此处简化
}
return tryLock(lockKey, expireSeconds);
}
/**
* 释放锁,验证是否为本线程所持有
*/
public void unlock(String lockKey) {
Map<String, String> keys = lockKeys.get();
String lockValue = keys.get(lockKey);
if (lockValue == null) {
return;
}
// 释放时用 Lua 脚本保证原子性:比较 value 并删除
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
lockValue
);
keys.remove(lockKey);
}
}
使用示例:
public void updateInventory(String productId, int quantity) {
String lockKey = "lock:inventory:" + productId;
if (lock.tryLock(lockKey, 10)) {
try {
// 执行业务逻辑,例如扣减库存
} finally {
lock.unlock(lockKey);
}
} else {
throw new BusinessException("操作频繁,请稍后重试");
}
}
4. 关键注意事项
- 锁过期时间必须大于业务执行时间:否则锁自动释放,其他线程可能重新获取锁,导致并发安全问题。实际项目中,常采用 看门狗机制(自动续期),如 Redisson 提供的
RLock会在后台周期性续约。 - 释放锁必须校验 value:避免因为业务阻塞导致锁过期后,其他线程获取了新锁,而当前线程误删了别人的锁。
- 生产级推荐直接使用 Redisson:Redisson 提供了完整的可重入锁、公平锁、读写锁、RedLock 算法实现,无需重复造轮子。
- RedLock 算法:在需要极高可靠性的场景,可采用多节点独立的 Redis 实例实现 RedLock,但复杂度较高,主流仍以单实例哨兵/集群 + 合理超时为主。
14.3.2 接口限流
1. 常见限流算法与 Redis 适用性
- 固定窗口计数器:简单,但存在临界突刺问题。
- 滑动窗口:平滑控制流量,使用有序集合(ZSET)实现更优。
- 令牌桶 / 漏桶:更精确的速率控制,常借助 Lua 脚本实现。
受篇幅所限,这里给出企业中最常用的 滑动窗口限流 实现,基于 ZSET 记录每次请求的时间戳,通过删除窗口外的旧记录来统计当前窗口内的请求数。
2. 滑动窗口限流实现
@Component
public class RedisRateLimiter {
private final StringRedisTemplate redisTemplate;
public RedisRateLimiter(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 滑动窗口限流
* @param key 限流标识(如接口路径、用户ID等)
* @param maxRequests 窗口内最大请求数
* @param windowSeconds 窗口时间(秒)
* @return true 表示允许通过,false 表示被限流
*/
public boolean allowRequest(String key, int maxRequests, long windowSeconds) {
long now = System.currentTimeMillis();
long windowStart = now - windowSeconds * 1000;
String redisKey = "rate_limit:" + key;
// Lua 脚本保证原子性:移除窗口外的记录,统计当前窗口计数,若未超限则添加当前请求
String luaScript =
"redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1]) " +
"local count = redis.call('ZCARD', KEYS[1]) " +
"if count < tonumber(ARGV[2]) then " +
" redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3] .. '-' .. ARGV[4]) " +
" redis.call('EXPIRE', KEYS[1], ARGV[5]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
Long result = redisTemplate.execute(script,
Collections.singletonList(redisKey),
String.valueOf(windowStart), // ARGV[1]
String.valueOf(maxRequests), // ARGV[2]
String.valueOf(now), // ARGV[3] 当前时间戳作为 score
UUID.randomUUID().toString(), // ARGV[4] 保证 member 唯一性
String.valueOf(windowSeconds + 1) // ARGV[5] key 的过期时间,略大于窗口
);
return Long.valueOf(1).equals(result);
}
}
使用示例(在拦截器或切面中):
public boolean preHandle(HttpServletRequest request, ...) {
String userId = (String) request.getAttribute("userId");
String limitKey = "api:/order:user:" + userId; // 对每个用户独立限流
if (!rateLimiter.allowRequest(limitKey, 10, 1)) { // 每秒最多10次
response.setStatus(429);
response.getWriter().write("请求过于频繁");
return false;
}
return true;
}
3. 方案对比与选型建议
- 简单固定窗口:使用
INCR配合EXPIRE,但临界问题可能导致两倍流量,不推荐。 - 滑动窗口 ZSET:平滑控制,内存占用与请求量成正比,适合中小规模精确限流。
- 令牌桶:适合允许突发流量的场景,可使用 Redis
DECR+EXPIRE结合定时补偿令牌,或直接集成 Sentinel / Redisson 提供的 RateLimiter。 - 生产级方案:推荐直接使用 Sentinel(结合 Redis)或网关层限流(Spring Cloud Gateway 集成 Redis),更稳定且支持可视化控制台。
14.3.3 计数器
1. Redis 计数器的优势
无论是对文章点赞数、视频播放量,还是接口调用次数、库存数量的管理,Redis 的原子递增/递减操作(INCR, HINCRBY)天生适合计数器场景:高性能、原子性、支持过期时间,还能与持久化机制结合做准实时统计。
2. 通用计数服务实现
@Component
public class RedisCounter {
private final StringRedisTemplate redisTemplate;
public RedisCounter(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
// 递增指定 key 的计数,返回递增后的值
public long increment(String key) {
return redisTemplate.opsForValue().increment(key);
}
// 递增指定增量值
public long increment(String key, long delta) {
return redisTemplate.opsForValue().increment(key, delta);
}
// 递减
public long decrement(String key) {
return redisTemplate.opsForValue().decrement(key);
}
// 带过期时间的计数器(适合限时活动统计)
public long incrementWithExpire(String key, long delta, long timeout, TimeUnit unit) {
Long count = redisTemplate.opsForValue().increment(key, delta);
// 仅在第一次设置时添加过期时间(避免每次覆盖)
if (count != null && count == delta) {
redisTemplate.expire(key, timeout, unit);
}
return count;
}
// 哈希计数器:适用于多个业务维度的计数
public long hincrby(String key, String field, long delta) {
return redisTemplate.opsForHash().increment(key, field, delta);
}
// 查询当前计数值
public long getCount(String key) {
String value = redisTemplate.opsForValue().get(key);
return value == null ? 0 : Long.parseLong(value);
}
}
典型应用场景示例:
- 文章阅读量:
counter.increment("article:read:123456")。 - 用户点赞数:
counter.hincrby("user:stat:1001", "likes", 1)。 - 库存扣减:使用
DECR并判断返回值是否大于等于0,防止超卖(需结合 Lua 脚本保证原子性判断)。 - 接口调用计数:配合定时任务将 Redis 计数定期同步到数据库,实现高性能写分散、准实时汇总。
3. 防止计数器数据丢失的建议
Redis 默认是内存数据库,虽然可以通过 RDB/AOF 进行持久化,但极端情况下仍可能丢失少量计数数据。根据业务要求可采取以下策略:
- 非强一致性业务(如点赞数、阅读量):直接使用 Redis,可接受微小误差。
- 强一致性业务(如余额、库存):必须结合数据库,使用 Redis 作为缓存,经过 Lua 脚本原子校验后,同步写入数据库,或采用可靠消息最终一致性方案。
- 定期备份与补偿:通过定时任务将 Redis 计数写入 MySQL,异常时从 DB 还原。
14.3.4 综合建议
Redis 在这三种场景的表现极为出色,但在实际落地时,需要根据业务特点选择合适的策略:
- 分布式锁优先考虑 Redisson,避免自己处理锁续期、可重入等复杂细节。
- 接口限流强烈建议使用成熟中间件(Sentinel、网关)配合 Redis,防止自研方案不完善导致流量失控。
- 计数器注意区分一致性和性能需求,结合数据库形成 “Redis + DB” 的稳定架构。
合理运用这些工具,能用极简的代码实现高并发下的有序控制,这正是 Redis 作为“性能银弹”的魅力所在。