人人都会AI编程

14.3 分布式锁、接口限流、计数器的 Redis 实现

更新时间:2026-07-11

Redis 凭借其单线程命令处理、原子操作和丰富的数据结构,成为实现分布式锁、接口限流、计数器等场景的首选组件。本节将从真实开发需求出发,给出可直接使用的实现方案和注意事项。

14.3.1 分布式锁

1. 为什么需要分布式锁

在单机环境下,使用 synchronizedReentrantLock 即可控制并发。但在多实例微服务环境中,多个服务实例可能同时处理同一条数据(例如扣减库存、防止重复创建订单),这时需要一个跨进程的同步机制——分布式锁。

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 作为“性能银弹”的魅力所在。