PAYATHON 2026

Java 8 与 Spring WebFlux 如何按请求体字段串行处理请求?

数据小陈

应分别对 referralId 和 slotId 建立互斥关系,并由数据库保证“一个时段最多被占用一次”。如果只需在同一 JVM 内协调请求,可以使用支持多个键的本地锁;服务部署了多个实例时,本地锁无法独立保证预约正确性。

Spring 不会因为请求体中的 slotId 相同就自动串行处理请求。@Transactional 也不会自动把普通的“先查询、后更新”变成互斥操作。

现有实现存在锁对象被提前移除的竞态

现有实现不易扩展,而且取得锁对象、增加引用计数和移除映射不是原子操作。

一种可能的执行顺序是:

  1. 线程 A 使用锁对象 M 完成业务操作,将计数减为 0,尚未执行 map.remove(referralId)。
  2. 线程 B 从 Map 中取得 M,增加计数,并开始执行受 M 保护的操作。
  3. 线程 A 继续执行 remove,删除了仍被 B 使用的映射。
  4. 线程 C 为同一个 referralId 创建新的锁对象 N。

此时 B 和 C 使用不同的锁,可以同时进入业务代码。

ConcurrentHashMap 保证其相应操作的线程安全,但不会自动保证这组调用构成的业务协议正确。仅把删除改成 map.remove(referralId, mon) 仍不足以修复这个问题,因为映射可能仍指向正在被使用的 M。相关原子更新能力见 Java 8 ConcurrentHashMap 文档。

单 JVM 可以使用固定分段锁,同时锁住多个业务键

一种易于维护的方式是预先创建固定数量的锁,再把业务键映射到这些锁。锁对象始终保留,不需要动态引用计数和清理逻辑。

每次操作先收集全部业务键,再将对应的锁去重、排序,按统一顺序获取。不能简单地把两个字段拼成一个组合键:(referralA, slot1) 和 (referralB, slot1) 的组合键不同,却仍然争用同一个时段。

下面的示例使用 Java 8 API:

import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Supplier;

@Component
public class KeyedSync {
    private final ReentrantLock[] locks = new ReentrantLock[256];

    public KeyedSync() {
        for (int i = 0; i < locks.length; i++) {
            locks[i] = new ReentrantLock();
        }
    }

    public <T> T withKeys(Supplier<T> action, String... keys) {
        Objects.requireNonNull(action, "action");
        Objects.requireNonNull(keys, "keys");
        if (keys.length == 0) {
            throw new IllegalArgumentException("At least one key is required");
        }

        int[] indexes = Arrays.stream(keys)
            .mapToInt(key ->
                (Objects.requireNonNull(key, "key").hashCode()
                    & Integer.MAX_VALUE) % locks.length)
            .distinct()
            .sorted()
            .toArray();

        int acquired = 0;
        try {
            for (int index : indexes) {
                locks[index].lock();
                acquired++;
            }
            return action.get();
        } finally {
            for (int i = acquired - 1; i >= 0; i--) {
                locks[indexes[i]].unlock();
            }
        }
    }
}

调用时分别传入两个带命名空间的键:

DbAppointmentResult src = sync.withKeys(
    () -> transactionTemplate.execute(status ->
        daoApi.setappointment(
            organizationId,
            referralId,
            patientId,
            slotId,
            previousSlotId
        )
    ),
    "referralId:" + referralId,
    "slotId:" + slotId
);

这里的 sync 是共享的 KeyedSync Bean,transactionTemplate 是已配置的 Spring TransactionTemplate。示例要求调用方没有已经开启的外层事务,且 DAO 同步完成操作,使事务在释放本地锁前提交或回滚。如果模板加入已有事务,模板返回时外层事务可能还未提交,必须重新调整边界。

使用时还需要注意:

  • referralId 和 slotId 应先通过参数校验,避免把缺失值拼成字符串 "null"。
  • 如果 ID 只在某个组织内唯一,锁键应按实际资源标识规则包含组织信息。
  • 如果改约过程也会修改 previousSlotId 对应的资源,应把旧时段键一并纳入。
  • 所有相关入口必须共用这组锁,并一次提交操作所需的全部键;不要持锁后再嵌套申请其他业务键。

不同业务键可能映射到同一把锁,无关请求因此偶尔也会互相等待。这会降低并发度,但不会削弱互斥保证。256 只是示例值,不是经过验证的容量建议。

如果不能接受这种额外等待,可以实现精确的按键锁注册表,但登记使用者和回收条目必须通过一致的原子协议完成,不能沿用原代码的计数方式。

数据库需要原子地完成时段占用

“状态没来得及更新”可能涉及事务提交时机、检查与写入之间的竞争,或其他执行路径。仅凭现有片段无法确定具体原因,需要检查数据库过程和事务边界。

若数据模型中每个时段对应一行,可以考虑条件更新。下面只是结构示意,表名、字段和可用状态判断需要按实际模型调整:

UPDATE appointment_slot
SET patient_id = :patientId
WHERE slot_id = :slotId
  AND patient_id IS NULL;

在 slot_id 唯一、数据库并发语义适用的前提下,以受影响行数判断结果:

  • 更新 1 行:当前事务取得了该时段,最终成功仍以事务提交为准。
  • 更新 0 行:没有符合条件的时段,可能已经被占用,也可能不存在,需要按业务区分。

例如,PostgreSQL 在 Read Committed 下对并发更新会等待冲突事务,并在适用情况下重新检查更新条件,具体行为见 PostgreSQL 事务隔离文档。原问题没有提供数据库类型,不能把这一行为直接当作当前环境已经验证的结论。

复杂过程也可以考虑在同一事务中锁定已有的时段记录,再检查和更新。如果同时操作多个资源,应统一数据库加锁顺序,并处理死锁或事务冲突。若使用唯一约束兜底,约束必须准确表达“同一时段只能有一条有效预约”,避免把允许保留的历史记录也限制住。

referralId 对应的业务限制同样需要数据库层面的保护。只修复时段占用,不能自动保证转诊相关操作正确。

WebFlux 中要把阻塞操作放到合适的线程上

ReentrantLock.lock() 会阻塞等待线程,传统 Hibernate/JDBC 操作也会阻塞。不要直接在 WebFlux 的事件循环线程上运行整段逻辑。Spring 对此并发模型的说明见 Spring WebFlux 文档。

应将获取锁、同步 DAO 调用以及事务完成这整段工作放到受控的阻塞工作线程池中。使用 Reactor 时,可以通过 Mono.fromCallable(...) 延迟执行,再用 subscribeOn(...) 指定项目版本支持的 Scheduler,并配置线程数、等待容量和超时策略。

不要只在锁内创建或返回 Mono:创建 Publisher 通常不等于业务已经执行完毕。如果实际数据库操作在释放锁后才开始,互斥就没有覆盖业务执行。

互斥执行不等于严格按请求到达顺序执行

上述代码保证这些本地调用不会同时修改共享资源,但不保证 HTTP 请求按到达顺序执行。默认 ReentrantLock 不保证获取顺序;公平锁也不能保证线程调度公平,见 Java 8 ReentrantLock 文档。

如果业务必须严格按顺序处理,需要先定义“到达”的统一登记点,再通过按键队列或统一调度实现排序。对于预约抢占,通常需要保证的是:成功提交占用后,后续竞争者得到明确的业务冲突结果。如果前一个请求回滚,后一个请求仍可能成功。

多实例部署时,各实例拥有不同的本地锁。数据库中的原子占用、约束和事务控制仍须承担最终的并发正确性保证。

备注:内容仅供参考。