跳转到正文

锁与限流

锁保护同一资源的临界区,限流控制一段时间内的调用速率。它们通过独立管理器使用,不属于 StoreTemplate 的数据操作,也不会出现在模板的键扫描结果中。

作用域锁

注入 StoreLockManager,将需要共同保护的操作放入回调。以下类放在应用扫描范围内:

java
package example.store;

import com.own.component.store.api.lock.StoreLockManager;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Collection;

@Service
public class GuardedActionService {

    private final StoreLockManager locks;

    public GuardedActionService(StoreLockManager locks) {
        this.locks = locks;
    }

    public void execute(String resourceKey, Runnable action) {
        locks.withLock(resourceKey, Duration.ofSeconds(2), action);
    }

    public boolean tryExecuteAll(Collection<String> resourceKeys, Runnable action) {
        return locks.tryWithLocks(resourceKeys, Duration.ofSeconds(2), action);
    }
}
方法等待与返回
withLock(key, action) / withLocks(keys, action)一直等待,取得所有锁后执行
withLock(key, waitTime, action) / withLocks(keys, waitTime, action)在正数等待时长内获取,超时抛出 StoreLockException
tryWithLock(key, waitTime, action) / tryWithLocks(keys, waitTime, action)取得并执行成功返回 true,超时返回 false 且不执行

with... 支持 RunnableSupplier<T>,后者直接返回回调结果,包括 null;tryWith... 接受 Runnable。回调异常继续传播,锁仍按释放策略处理。限时等待被中断时恢复线程中断标记并抛出 StoreLockException

waitTime 控制获取锁的等待时间,不限制回调运行时间,也不是锁租约。回调中的异步任务在返回后继续执行时,不再属于原临界区;需要保护的操作应在持锁线程内完成。

批量锁与共享范围

批量锁要求非空集合,先校验所有键并去重,再按稳定顺序加锁。Redis 按物理键排序,本地按锁分片再按逻辑键排序。只有全部成功后才执行一次回调;限时方法的预算由整组共享,任何一把锁超时都会释放本次此前取得的锁。

无事务时按获取顺序逆序释放;同步事务中可保留到事务完成。批量排序减少同批资源顺序不同导致的循环等待,但不能替代应用对嵌套调用、跨批次持锁顺序的约定。

维度本地Redis
共享范围同一个锁管理器实例相同 Redis 目标、数据库、命名空间与逻辑键
锁数量固定分片,默认 1024按逻辑键取得 Redisson 锁
键碰撞不同键映射到同一分片会串行不同物理键独立竞争
命名空间管理器本身不接收命名空间自动配置使用 own.store.namespace

多实例互斥必须使用共享锁;仅把数据后端换成 Redis 而保留自定义本地锁并不充分。锁只协调遵守同一协议的调用方,不替代数据库唯一约束。多次写入的原子提交仍需要数据库事务。

方法级锁注解

Starter 自动提供切面,在受 Spring AOP 代理的方法上使用 @StoreLockAop

java
package example.store;

import com.own.component.store.api.lock.StoreLockAop;
import org.springframework.stereotype.Service;

import java.util.Collection;
import java.util.concurrent.TimeUnit;

@Service
public class AnnotatedActionService {

    @StoreLockAop(keys = {"resource:#{#p0}", "#p1"},
            waitTime = 2, timeUnit = TimeUnit.SECONDS)
    public String execute(String resourceId, Collection<String> relatedKeys) {
        return "locked:" + resourceId;
    }
}

从另一个 Bean 调用该代理方法,切面会先解析全部键,取得整组锁后只调用一次方法。示例的 relatedKeys 是额外的完整逻辑键集合,#p0#p1 按参数位置引用,不依赖编译时保留参数名。

声明解释
"lookup:global"固定逻辑键
"resource:#{#p0}"SpEL 模板,拼接动态值
"#p1"直接求值,数组或 Iterable 展开为多个键
"@beanName.method(#p0)""T(...).method(...)"Bean 或类型表达式,返回值继续按键解析

标量结果转为字符串,最终键去重;null、空白键、空声明及整体没有解析出任何键都会失败。命名参数如 #resourceId 需要可发现的参数名,通用示例优先使用位置参数。表达式应由应用代码固定,不接受外部用户输入作为表达式文本。

waitTime 默认 -1 表示一直等待;正数为整组总预算,单位默认毫秒。零或小于 -1 在调用时抛出参数异常。与其他 Spring AOP 注解一样,手动 new 对象、自调用或无法被代理拦截的方法不会获得该锁保护。

事务中的释放时机

当 Spring 事务相关类存在、使用自动配置的释放策略,且获取锁时已经处于同步事务中,第一次物理锁会登记到事务同步回调,直到提交或回滚完成后才释放。同一事务通过同一个管理器再次获取相同键会逻辑重入,不增加物理加锁次数。

应用应先通过事务代理进入事务,再调用持锁操作。例如已有事务管理器并启用事务代理时,可在外层 @Transactional 方法中调用 StoreLockManager.withLock;或由事务服务调用另一个带锁注解的 Bean。不要依赖同一方法同时标注 @Transactional@StoreLockAop 的默认切面顺序来保证事务先于加锁。

没有活动事务时,回调结束立即释放。事务活动但无法登记同步回调时,抛出 StoreLockException,不会静默提前释放后继续执行。手动创建管理器默认立即释放,除非显式传入其他 StoreLockReleaseStrategy

该机制不跨线程,不覆盖响应式事务或独立 REQUIRES_NEW 事务的逻辑重入。回调结束后仍持锁的时间取决于整个事务长度;不要在其中进行无关的长时间等待。Store 写入本身不会加入数据库事务,缓存的提交边界见失效与事务

按键限流

注入 StoreRateLimiterManager,每次使用前以相同策略配置,再获取许可:

java
package example.store;

import com.own.component.store.api.limit.RateLimitPolicy;
import com.own.component.store.api.limit.StoreRateLimiterManager;
import org.springframework.stereotype.Service;

import java.time.Duration;

@Service
public class RequestRateService {

    private final StoreRateLimiterManager limiters;
    private final RateLimitPolicy policy =
            new RateLimitPolicy(10, Duration.ofSeconds(1));

    public RequestRateService(StoreRateLimiterManager limiters) {
        this.limiters = limiters;
    }

    public boolean allow(String callerId) {
        String key = "lookup:" + callerId;
        limiters.configure(key, policy);
        return limiters.tryAcquire(key, 1);
    }
}

configure 创建或更新策略。同一键用完全相同策略重复配置不会补满已消费许可;换成不同策略会重置旧消费状态。多个实例应采用一致策略,避免来回覆盖。

tryAcquire(key, permits) 立即尝试并返回是否获得许可;acquire(key, permits) 会阻塞到获得许可,没有超时参数,适合后台限速任务。策略许可数和单次申请数均需为正,申请量不应超过配置许可量;兼容本地与 Redis 的调用将单次申请控制在 Integer.MAX_VALUE 以内,并使用至少一毫秒的 interval。

限流后端差异

维度本地Redis
算法入口Guava RateLimiter,按每秒速率平滑发放Redisson RateType.OVERALL,共享许可状态
共享范围当前管理器实例相同 Redis 目标、数据库、命名空间与键
interval正 Duration,换算为每秒速率至少一毫秒,按毫秒处理
单次申请数1..Integer.MAX_VALUE正 long
生命周期容量淘汰或一小时未访问后可能丢失配置保留在 Redis,Store 未暴露限流器删除 API

相同策略不意味着完全相同的瞬时突发行为,本地实现也不是严格的固定窗口计数。本地限流器被淘汰后,未重新配置就使用会失败;因此示例在每次调用前幂等配置,但容量淘汰仍意味着消费状态可能重建。使用 Redis 动态生成大量限流键时,应由应用规划键数量与清理方式。

组合锁与限流时要明确许可消费时机:先取许可再竞争锁,锁失败也已消费许可;先持锁再取许可,则限流判断属于临界区的一部分。组件不会自动退还许可,也没有内置的 HTTP 限流注解或响应封装。

示例涉及的源码类型

RateLimitPolicy