You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring @Transactional+REPEATABLE_READ实现API月限流的并发一致性问题求解

解决多服务器环境下API月度请求计数的并发更新问题

你遇到的是典型的分布式并发更新场景——多线程/多节点下的计数更新,单纯依赖Spring事务的隔离级别或者JVM同步块确实解决不了,因为同步块只能管单个JVM内的线程,而REPEATABLE_READ隔离级别在"先查后更"的模式下会导致快照读的旧数据被用来更新。下面给你几个可行的解决方案,按实现复杂度和推荐程度排序:

方案1:数据库原子更新(最推荐,无额外依赖)

核心思路是把计算和更新合并成一条原子SQL语句,避免先查询后更新的竞态条件。数据库的UPDATE语句本身是原子性的,会直接在行上加写锁,确保同一时间只有一个请求能更新这条记录。

伪代码实现(结合Spring)

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateLimitData(String clientId, int currentRecordCount) {
    long startOfMonth = getStartOfMonth();
    long endOfMonth = getEndOfMonth();
    
    // 先尝试更新现有记录
    int updatedRows = jdbcTemplate.update(
        "UPDATE client_monthly_limit SET count = count + ? WHERE client_id = ? AND timestamp_start >= ? AND timestamp_end <= ?",
        currentRecordCount, clientId, startOfMonth, endOfMonth
    );
    
    // 如果没有找到当月记录,插入新记录
    if (updatedRows == 0) {
        jdbcTemplate.update(
            "INSERT INTO client_monthly_limit(client_id, count, timestamp_start, timestamp_end) VALUES (?, ?, ?, ?)",
            clientId, currentRecordCount, startOfMonth, endOfMonth
        );
    }
}

为什么有效?

  • UPDATE语句是数据库层面的原子操作,执行时会锁定目标行,其他请求必须等待锁释放才能执行。
  • 不需要依赖额外组件,完全利用数据库本身的特性,稳定性高。

方案2:乐观锁机制

如果你的业务逻辑需要先读取计数做一些额外判断,再更新,可以用乐观锁。在表中新增一个version字段(或者用count本身作为版本,但不推荐),更新时带上版本号,更新失败则重试。

表结构调整

新增version列,类型为INT,每次更新自增。

伪代码实现

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateLimitData(String clientId, int currentRecordCount) {
    long startOfMonth = getStartOfMonth();
    long endOfMonth = getEndOfMonth();
    
    // 读取当前记录和版本号
    ClientMonthlyLimit limit = jdbcTemplate.queryForObject(
        "SELECT * FROM client_monthly_limit WHERE client_id = ? AND timestamp_start >= ? AND timestamp_end <= ?",
        new Object[]{clientId, startOfMonth, endOfMonth},
        new BeanPropertyRowMapper<>(ClientMonthlyLimit.class)
    );
    
    int newCount = limit.getCount() + currentRecordCount;
    int updatedRows = jdbcTemplate.update(
        "UPDATE client_monthly_limit SET count = ?, version = version + 1 WHERE client_id = ? AND timestamp_start >= ? AND timestamp_end <= ? AND version = ?",
        newCount, clientId, startOfMonth, endOfMonth, limit.getVersion()
    );
    
    // 如果更新失败(说明有其他线程先更新了),可以重试或者抛出异常
    if (updatedRows == 0) {
        // 这里可以实现重试逻辑,比如最多重试3次
        throw new ConcurrentUpdateException("Concurrent update detected, please retry");
    }
}

注意点

  • 需要处理更新失败的情况,通常是重试几次,或者返回给客户端让其重试。
  • 适合并发量不是极高的场景,因为高并发下重试次数可能增多。

方案3:分布式锁(适合复杂场景)

如果你的系统已经引入了Redis等分布式缓存,可以用分布式锁来确保同一客户端当月的更新只有一个线程在执行。比如用Redisson的可重入锁,锁的key可以设为client_limit:{clientId}:{yearMonth}。

伪代码实现(用Redisson)

@Autowired
private RedissonClient redissonClient;

public void updateLimitData(String clientId, int currentRecordCount) {
    String yearMonth = getYearMonth(); // 比如"202405"
    String lockKey = String.format("client_limit:%s:%s", clientId, yearMonth);
    RLock lock = redissonClient.getLock(lockKey);
    
    try {
        // 尝试获取锁,最多等待5秒,锁持有10秒(根据业务调整)
        if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
            // 这里用原来的事务逻辑,因为锁已经确保了串行执行
            doUpdateInTransaction(clientId, currentRecordCount);
        } else {
            throw new LockAcquireException("Failed to acquire lock, please retry");
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException("Lock interrupted", e);
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
private void doUpdateInTransaction(String clientId, int currentRecordCount) {
    long startOfMonth = getStartOfMonth();
    long endOfMonth = getEndOfMonth();
    ClientMonthlyLimit limit = fetchFromDB(startOfMonth, endOfMonth, clientId);
    limit.setCount(limit.getCount() + currentRecordCount);
    saveToDB(limit);
}

注意点

  • 依赖Redis等分布式组件,需要确保组件的高可用性。
  • 要处理锁超时、锁释放失败等异常情况,避免死锁。

为什么原来的方案会出问题?

你原来的代码用了Isolation.REPEATABLE_READ,在这种隔离级别下,事务中的读取是快照读——第二个线程在第一个线程提交前读取的是事务开始时的快照数据,而不是最新的已提交数据。所以当第一个线程提交更新后,第二个线程还是用旧的快照数据去更新,导致计数被覆盖。

内容的提问来源于stack exchange,提问作者Sreeram

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.15 08:07:48