智慧医院排队叫号系统 —— 技术详解

基于 Redis ZSET + Lua 原子脚本 实现高并发排队调度,Spring WebSocket (STOMP) + Redis Pub/Sub 实现队列状态实时推送


一、架构总览

1.1 系统数据流

┌──────────────────────────────────────────────────────────────────────────┐
│                            签到入队流程                                   │
│                                                                          │
│  Kiosk自助机                                                             │
│    ↓ POST /kiosk/queue/signin                                            │
│  SignInService.signIn()                                                  │
│    ├── 校验挂号记录 → 判断患者类型(初诊/复诊/急诊)                           │
│    ├── 生成排队号 (A001/P001/Y001) → 插入 DB queue 表                     │
│    ├── 执行 enqueue.lua (ZADD + ZRANK + ZCARD) ← 原子入队                │
│    └── QueueEventPublisher.publish("SIGN_IN")                            │
│          └── redis.convertAndSend("queue:event:{dept}:{doc}", msg)       │
│                                                                          │
├──────────────────────────────────────────────────────────────────────────┤
│                            叫号出队流程                                   │
│                                                                          │
│  医生管理端                                                               │
│    ↓ POST /admin/queue/call-next                                         │
│  QueueService.callNext()                                                 │
│    ├── 执行 zpopmin_batch.lua (ZPOPMIN) ← 原子弹出队首                   │
│    ├── 更新 DB 状态为 CALLING                                            │
│    └── QueueEventPublisher.publish("CALL_NEXT")                          │
│          └── redis.convertAndSend("queue:event:{dept}:{doc}", msg)       │
│                                                                          │
├──────────────────────────────────────────────────────────────────────────┤
│                          实时推送流程                                     │
│                                                                          │
│  QueueEventPublisher (Redis Pub/Sub 发布)                                │
│    ↓ channel: queue:event:{deptId}:{docId}                               │
│  RedisPubSubBridge (Redis 订阅 → 反序列化)                               │
│    ↓ SimpMessagingTemplate.convertAndSend()                              │
│  WebSocket STOMP → 前端订阅 /topic/queue/{deptId}/{docId} 实时刷新       │
└──────────────────────────────────────────────────────────────────────────┘

1.2 技术选型理由

技术点

选型

理由

排队数据结构

Redis ZSET (Sorted Set)

天然有序,score 可计算优先级,O(logN) 入队/出队

并发安全

Lua 原子脚本

避免 ZRANGE + ZADD 的竞态条件,保证原子性

实时推送

STOMP over WebSocket

双向通信,支持主题订阅,浏览器原生支持

跨实例推送

Redis Pub/Sub

多实例部署时,非执行操作的实例也能收到事件并推送 WebSocket

1.3 核心 Redis Key 设计

Key 模式

类型

用途

queue:outpatient:{deptId}:{docId}

ZSET

每个医生的独立排队队列

queue:event:{deptId}:{docId}

Pub/Sub Channel

排队事件通知通道

schedule:lock:{scheduleId}

String (分布式锁)

号源锁定,防超卖


二、Redis ZSET + Lua 原子脚本 —— 高并发排队调度

2.1 为什么需要 Lua 脚本?

问题场景: 假设两个复诊患者同时签到,如果没有原子性保证:

时间线:
  T1: 请求A ZRANGE 获取队首 score=100
  T2: 请求B ZRANGE 获取队首 score=100
  T3: 请求A ZADD score=100+3*600000=1900100
  T4: 请求B ZADD score=100+3*600000=1900100  ← score 冲突!

Lua 脚本保证: 整个 ZRANGE + 计算 + ZADD 在 Redis 单线程中原子执行,不会被其他命令打断。

2.2 Lua 脚本详解

2.2.1 enqueue.lua —— 签到入队(支持优先级调度)

路径: hospital-service/src/main/resources/lua/enqueue.lua

local member = "queue:" .. ARGV[1]       -- member = "queue:{queueId}"
local signTs = tonumber(ARGV[3])          -- 签到时间戳
local N = tonumber(ARGV[4])              -- 复诊插队系数,默认3
local step = tonumber(ARGV[5])           -- 步长毫秒数,默认600000(10分钟)
local emergencyOffset = tonumber(ARGV[6]) -- 急诊优先偏移量,默认86400000(1天)
​
-- ★ 急诊:score 大幅提前,确保最高优先级
if ARGV[2] == "EMERGENCY" then
    redis.call("ZADD", KEYS[1], signTs - emergencyOffset, member)
​
-- ★ 复诊:插入队首后第N个位置
elseif ARGV[2] == "REVISIT" then
    local firsts = redis.call("ZRANGE", KEYS[1], 0, 0, "WITHSCORES")
    if #firsts > 0 then
        local firstScore = tonumber(firsts[2])
        local score = firstScore + N * step       -- 队首score + 3*10分钟
        local existing = redis.call("ZSCORE", KEYS[1], member)
        if existing then score = score + 1 end    -- 防止 score 重复
        redis.call("ZADD", KEYS[1], score, member)
    else
        redis.call("ZADD", KEYS[1], signTs, member)  -- 队列为空直接用时间戳
    end
​
-- ★ 初诊:按签到时间自然排序
else
    redis.call("ZADD", KEYS[1], signTs, member)
end
​
-- 返回 [当前排名, 队列总人数]
local rank = redis.call("ZRANK", KEYS[1], member)
return { rank, redis.call("ZCARD", KEYS[1]) }

三种优先级策略可视化:

ZSET (按 score 升序排列,score 越小越先出队):
​
┌─────────────────────────────────────────────────────────────────┐
│ 急诊患者入队                                                      │
│                                                                 │
│ score = signTs - 86400000 (提前1天)                              │
│                                                                 │
│ 入队前: [初诊A:1000] → [初诊B:2000] → [复诊C:2800000]            │
│ 入队后: [急诊D:-86399000] → [初诊A:1000] → [初诊B:2000] → ...   │
│          ↑ 急诊直接排到最前面                                      │
└─────────────────────────────────────────────────────────────────┘
​
┌─────────────────────────────────────────────────────────────────┐
│ 复诊患者入队                                                      │
│                                                                 │
│ score = 队首score + 3 × 600000(10min) = 队首score + 30min      │
│                                                                 │
│ 入队前: [初诊A:1000] → [初诊B:2000] → [初诊E:5000]              │
│ 入队后: [初诊A:1000] → [初诊B:2000] → [复诊F:1801000] → [初诊E]  │
│          ↑ 队首        ↑ 复诊插在队首后第3个位置                   │
└─────────────────────────────────────────────────────────────────┘
​
┌─────────────────────────────────────────────────────────────────┐
│ 初诊患者入队                                                      │
│                                                                 │
│ score = signTs (签到时间戳,自然排序)                              │
│                                                                 │
│ 谁先签到谁排前面,时间决定顺序                                      │
└─────────────────────────────────────────────────────────────────┘

参数说明表:

参数

值

含义

N

3

复诊患者插到队首后第3个位置

STEP_MS

600,000 (10分钟)

每个位置的"时间间隔"

EMERGENCY_OFFSET_MS

86,400,000 (1天)

急诊比签到时间提前1天

2.2.2 reenter_skip.lua —— 过号重入(插队策略)

路径: hospital-service/src/main/resources/lua/reenter_skip.lua

local member = "queue:" .. ARGV[1]
local firsts = redis.call("ZRANGE", KEYS[1], 0, 0, "WITHSCORES")
if #firsts == 0 then
    -- 队列为空,直接入队
    redis.call("ZADD", KEYS[1], tonumber(ARGV[3]), member)
else
    -- 插入队首后第 halfN 个位置 (halfN = N/2 = 1)
    local firstScore = tonumber(firsts[2])
    local step = tonumber(ARGV[4])
    local score = firstScore + tonumber(ARGV[2]) * step
    redis.call("ZADD", KEYS[1], score, member)
end
return redis.call("ZRANK", KEYS[1], member)

逻辑说明:

  • 过号患者重入时,halfN = N/2 = 1,即插入到 队首后第1个位置

  • 比复诊(第3个位置)优先级更高,但不会抢占队首(尊重当前正在候诊的第一位)

  • 最多允许过号2次(callCount >= 2 时拒绝重入)

过号重入:
  入队前: [初诊A:1000] → [初诊B:2000] → [初诊C:3000]
  入队后: [初诊A:1000] → [过号D:601000] → [初诊B:2000] → [初诊C:3000]
           ↑ 队首保留      ↑ 过号插在队首后第1位

2.2.3 zpopmin_batch.lua —— 叫号出队(原子弹出队首)

路径: hospital-service/src/main/resources/lua/zpopmin_batch.lua

local results = redis.call("ZPOPMIN", KEYS[1], tonumber(ARGV[1]))
if not results or #results == 0 then return nil end
return results[1]   -- 返回弹出的 member (如 "queue:42")

说明:

  • ZPOPMIN 原子弹出 score 最小的成员(即排在最前面的患者)

  • ARGV[1] 控制弹出数量,当前固定传 "1"

  • 返回值是被弹出的 member 字符串,Java 端解析出 queueId

2.3 Java Service 层 —— Lua 脚本调用

2.3.1 SignInService.java —— 签到服务

路径: hospital-service/.../service/registration/SignInService.java

@Service
@RequiredArgsConstructor
public class SignInService {
​
    // ★ 优先级调度常量
    private static final long N = 3;                    // 复诊插队系数
    private static final long STEP_MS = 600_000;        // 步长:10分钟
    private static final long EMERGENCY_OFFSET_MS = 86_400_000; // 急诊偏移:1天
​
    private final RedisTemplate<String, Object> redisTemplate;
    private final QueueEventPublisher eventPublisher;
    private DefaultRedisScript<List> enqueueScript;
    private DefaultRedisScript<Long> reenterSkipScript;
​
    // ★ 启动时加载 Lua 脚本
    @PostConstruct
    void init() {
        enqueueScript = new DefaultRedisScript<>();
        enqueueScript.setLocation(new ClassPathResource("lua/enqueue.lua"));
        enqueueScript.setResultType(List.class);
​
        reenterSkipScript = new DefaultRedisScript<>();
        reenterSkipScript.setLocation(new ClassPathResource("lua/reenter_skip.lua"));
        reenterSkipScript.setResultType(Long.class);
    }
​
    private String redisKey(Long departmentId, Long doctorId) {
        return "queue:outpatient:" + departmentId + ":" + doctorId;
    }
​
    // ★★★ 签到入队 ★★★
    @Transactional
    public Queue signIn(Long patientId, String regNo) {
        // 1. 校验挂号记录
        Registration reg = registrationMapper.selectOne(/* ... */);
        
        // 2. 判断患者类型(初诊/复诊/急诊)
        String patientType = resolvePatientType(patientId, reg.getDoctorId(), reg.getRegDate());
​
        // 3. 更新挂号状态为已签到
        reg.setStatus("SIGNED_IN");
        reg.setSignTime(LocalDateTime.now());
​
        // 4. 生成排队号 (A001/P001/Y001 格式)
        String prefix = reg.getRegDate().equals(LocalDate.now()) ?
                (reg.getTimePeriod().equals("AM") ? "A" : "P") : "Y";
        String queueNo = prefix + String.format("%03d", existingWaiting + 1);
​
        // 5. 插入 DB queue 表
        Queue queue = new Queue();
        queue.setQueueNo(queueNo);
        queue.setStatus("WAITING");
        queue.setType(patientType);
        queueMapper.insert(queue);
​
        // 6. ★ 执行 Lua 脚本原子入队
        String key = redisKey(reg.getDepartmentId(), reg.getDoctorId());
        long signTs = System.currentTimeMillis();
        redisTemplate.execute(enqueueScript,
                Collections.singletonList(key),     // KEYS[1] = ZSET key
                String.valueOf(queue.getId()),      // ARGV[1] = queueId
                patientType,                        // ARGV[2] = 患者类型
                String.valueOf(signTs),             // ARGV[3] = 签到时间戳
                String.valueOf(N),                  // ARGV[4] = 插队系数
                String.valueOf(STEP_MS),            // ARGV[5] = 步长
                String.valueOf(EMERGENCY_OFFSET_MS) // ARGV[6] = 急诊偏移
        );
​
        // 7. 发布签到事件
        eventPublisher.publish(queue, "SIGN_IN");
​
        // 8. 查询当前等待位次
        Long rank = redisTemplate.opsForZSet().rank(key, "queue:" + queue.getId());
        if (rank != null) queue.setWaitCount(rank.intValue());
​
        return queue;
    }
​
    // ★★★ 过号重入 ★★★
    @Transactional
    public Queue reenterSkip(Long patientId, String regNo) {
        Queue skipped = queueMapper.selectOne(/* status=SKIPPED */);
        if (skipped.getCallCount() >= 2) {
            throw new BizException("已过号两次,请到导医台处理");
        }
​
        skipped.setStatus("WAITING");
        skipped.setCallCount(0);
​
        // ★ 执行过号重入 Lua 脚本 (halfN = N/2 = 1,插到队首后第1位)
        long halfN = N / 2;
        redisTemplate.execute(reenterSkipScript,
                Collections.singletonList(key),
                String.valueOf(skipped.getId()),    // ARGV[1] = queueId
                String.valueOf(halfN),              // ARGV[2] = 1 (插队位置)
                String.valueOf(signTs),             // ARGV[3] = 时间戳
                String.valueOf(STEP_MS)             // ARGV[4] = 步长
        );
​
        eventPublisher.publish(skipped, "REENTER");
        return skipped;
    }
​
    // 判断患者类型:当天看过同一医生 → 复诊,否则 → 初诊
    private String resolvePatientType(Long patientId, Long doctorId, LocalDate regDate) {
        List<Registration> todayDone = registrationMapper.selectList(/* ... SEEN ... */);
        return todayDone.isEmpty() ? "FIRST_VISIT" : "REVISIT";
    }
}

2.3.2 QueueService.java —— 排队调度核心服务

路径: hospital-service/.../service/registration/QueueService.java

@Service
@RequiredArgsConstructor
public class QueueService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final QueueEventPublisher eventPublisher;
    private DefaultRedisScript<Object> zpopminScript;

    @PostConstruct
    void init() {
        zpopminScript = new DefaultRedisScript<>();
        zpopminScript.setLocation(new ClassPathResource("lua/zpopmin_batch.lua"));
        zpopminScript.setResultType(Object.class);
    }

    // ★★★ 叫下一位 ★★★
    @Transactional
    public Queue callNext(Long departmentId, Long doctorId) {
        String key = redisKey(departmentId, doctorId);
        // ★ 执行 Lua 脚本原子弹出队首
        Object raw = redisTemplate.execute(zpopminScript, Collections.singletonList(key), "1");
        if (raw == null) throw new BizException("队列已空");

        Long queueId = Long.valueOf(raw.toString().replace("queue:", ""));
        Queue queue = queueMapper.selectById(queueId);

        queue.setStatus("CALLING");
        queue.setCallTime(LocalDateTime.now());
        queue.setCallCount(queue.getCallCount() + 1);
        queueMapper.updateById(queue);

        eventPublisher.publish(queue, "CALL_NEXT");
        return queue;
    }

    // ★★★ 重呼 ★★★
    @Transactional
    public Queue recall(Long queueId) {
        Queue queue = queueMapper.selectById(queueId);
        queue.setStatus("CALLING");
        queue.setCallTime(LocalDateTime.now());
        queue.setCallCount(queue.getCallCount() + 1);
        queueMapper.updateById(queue);
        eventPublisher.publish(queue, "RECALL");
        return queue;
    }

    // ★★★ 过号 ★★★
    @Transactional
    public Queue skip(Long queueId) {
        Queue queue = queueMapper.selectById(queueId);
        queue.setStatus("SKIPPED");
        queueMapper.updateById(queue);
        eventPublisher.publish(queue, "SKIP");
        return queue;
    }

    // ★★★ 开始就诊 ★★★
    @Transactional
    public Queue startConsultation(Long queueId) {
        Queue queue = queueMapper.selectById(queueId);
        queue.setStatus("SEEING");
        queueMapper.updateById(queue);
        eventPublisher.publish(queue, "START_DIAG");
        return queue;
    }

    // ★★★ 完成就诊 ★★★
    @Transactional
    public Queue complete(Long queueId) {
        Queue queue = queueMapper.selectById(queueId);
        queue.setStatus("DONE");
        queueMapper.updateById(queue);

        // ★ 从 ZSET 中移除(兜底清理,正常在 callNext 时已 ZPOPMIN)
        String member = "queue:" + queueId;
        redisTemplate.opsForZSet().remove(redisKey(queue.getDepartmentId(), queue.getDoctorId()), member);

        // 同步更新挂号状态
        Registration reg = registrationMapper.selectById(queue.getRegistrationId());
        if (reg != null) {
            reg.setStatus("SEEN");
            registrationMapper.updateById(reg);
        }

        eventPublisher.publish(queue, "COMPLETE");
        return queue;
    }

    // ★★★ 面板数据 ★★★
    public QueuePanelVO getPanelData(Long departmentId, Long doctorId) {
        String key = redisKey(departmentId, doctorId);
        // ZRANGE 获取全部等待成员
        Set<Object> members = redisTemplate.opsForZSet().range(key, 0, -1);

        QueueVO current = getCurrentCallingVO(departmentId, doctorId);  // 当前呼叫
        List<QueueVO> waitingList = /* 遍历 members 构建等待列表 */;

        QueuePanelVO panel = new QueuePanelVO();
        panel.setCurrentCalling(current);
        panel.setWaitingList(waitingList);
        panel.setTotalWaiting((long) waitingList.size());
        return panel;
    }

    // ★★★ 等待人数 ★★★
    public Long getWaitCount(Long departmentId, Long doctorId) {
        String key = redisKey(departmentId, doctorId);
        Long size = redisTemplate.opsForZSet().size(key);  // ZCARD
        return size != null ? size : 0L;
    }
}

2.4 排队号生成规则

┌──────────┬──────────────────────┬───────────────┐
│ 前缀字母  │ 含义                  │ 示例           │
├──────────┼──────────────────────┼───────────────┤
│ A        │ 当日上午号源签到       │ A001, A002    │
│ P        │ 当日下午号源签到       │ P001, P002    │
│ Y        │ 预约号(非当日号源)   │ Y001, Y002    │
└──────────┴──────────────────────┴───────────────┘

编号规则: 前缀 + 3位序号 (如 A001)
序号 = 当前该医生 WAITING 状态的排队数 + 1

2.5 Redis 配置 —— 全 String 序列化

路径: hospital-web/.../config/RedisConfig.java

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        // ★ 全部使用 String 序列化,保证 Lua 脚本参数和返回值一致
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new StringRedisSerializer());
        return template;
    }
}

为什么全部用 StringRedisSerializer?

  • Lua 脚本操作的是字符串形式的 key/member

  • 如果 Value 用 JDK 序列化,ZSET 的 member 会变成乱码,Lua 无法正确解析

  • 统一 String 序列化确保 Lua 脚本中的 ZRANGE、ZPOPMIN 返回值可直接使用


三、Spring WebSocket (STOMP) + Redis Pub/Sub —— 队列状态实时推送

3.1 为什么需要 Redis Pub/Sub?

┌──────────────────────────────────────────────────────────┐
│  单实例场景 (不需要 Pub/Sub)                               │
│                                                          │
│  签到操作和 WebSocket 推送在同一个 JVM,                    │
│  直接调用 SimpMessagingTemplate 即可                       │
└──────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────┐
│  多实例场景 (需要 Pub/Sub)                                 │
│                                                          │
│  实例A: 患者签到 → 执行 Lua 入队                           │
│  实例B: 医生端 WebSocket 连接在这里                        │
│                                                          │
│  问题: 实例A 的签到事件怎么推送到实例B 的 WebSocket?        │
│  答案: Redis Pub/Sub 做跨实例消息广播!                     │
└──────────────────────────────────────────────────────────┘

3.2 整体推送链路

SignInService / QueueService
    ↓ (业务操作完成后)
QueueEventPublisher.publish()
    ↓ redisTemplate.convertAndSend()
    ↓ channel: "queue:event:{deptId}:{docId}"
RedisPubSubBridge (监听 Redis Pub/Sub)
    ↓ MessageListener 收到消息
    ↓ 反序列化为 Map
    ↓ messagingTemplate.convertAndSend()
    ↓ STOMP topic: "/topic/queue/{deptId}/{docId}"
浏览器前端 (SockJS + STOMP.js)
    ↓ stompClient.subscribe("/topic/queue/{deptId}/{docId}")
    ↓ 收到推送 → 更新 UI

3.3 核心代码详解

3.3.1 WebSocketConfig.java —— WebSocket 配置

路径: hospital-web/.../config/WebSocketConfig.java

@Configuration
@EnableWebSocketMessageBroker   // ★ 启用 WebSocket 消息代理
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // ★ SimpleBroker: 服务端可向这些前缀的目的地推送消息
        config.enableSimpleBroker("/topic", "/queue");
        // ★ 客户端发送消息的前缀 (本项目未使用客户端→服务端消息)
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // ★ STOMP 端点,前端通过此 URL 建立 WebSocket 连接
        registry.addEndpoint("/ws-stomp")
                .setAllowedOriginPatterns("*")  // 允许跨域
                .withSockJS();                   // SockJS 降级支持
    }
}

配置说明:

配置项

值

说明

SimpleBroker

/topic, /queue

服务端推送目的地前缀,/topic 用于广播,/queue 用于点对点

ApplicationDestinationPrefix

/app

客户端发送消息时使用的前缀

STOMP Endpoint

/ws-stomp

前端连接 WebSocket 的 URL

SockJS

启用

浏览器不支持 WebSocket 时自动降级

3.3.2 QueueEventPublisher.java —— 事件发布器

路径: hospital-service/.../service/queue/QueueEventPublisher.java

@Component
@RequiredArgsConstructor
public class QueueEventPublisher {

    private final RedisTemplate<String, Object> redisTemplate;
    private static final ObjectMapper MAPPER = new ObjectMapper();

    public void publish(Queue queue, String eventType) {
        // ★ 构造事件消息
        Map<String, Object> msg = new HashMap<>();
        msg.put("type", eventType);              // 事件类型
        msg.put("queueId", queue.getId());       // 排队ID
        msg.put("queueNo", queue.getQueueNo());  // 排队号
        msg.put("status", queue.getStatus());    // 当前状态
        msg.put("departmentId", queue.getDepartmentId());
        msg.put("doctorId", queue.getDoctorId());
        msg.put("timestamp", System.currentTimeMillis());

        // ★ 发布到 Redis Pub/Sub
        try {
            redisTemplate.convertAndSend(
                "queue:event:" + queue.getDepartmentId() + ":" + queue.getDoctorId(),
                MAPPER.writeValueAsString(msg)
            );
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Failed to serialize queue event", e);
        }
    }
}

事件类型一览:

事件类型

触发场景

含义

SIGN_IN

患者签到入队

新患者加入队列

CALL_NEXT

医生叫下一位

队首患者被叫号

RECALL

医生重呼

重新呼叫当前患者

SKIP

医生标记过号

患者未到,标记为过号

START_DIAG

医生开始就诊

患者进入诊室

COMPLETE

医生完成就诊

患者就诊结束

REENTER

过号患者重新入队

过号患者重新排队

3.3.3 RedisPubSubBridge.java —— Redis Pub/Sub → STOMP 桥接(核心)

路径: hospital-web/.../config/RedisPubSubBridge.java

@Slf4j
@Configuration
@RequiredArgsConstructor
public class RedisPubSubBridge {

    private final SimpMessagingTemplate messagingTemplate;
    private final RedisTemplate<String, Object> redisTemplate;
    private final ObjectMapper objectMapper;

    // ★ Redis 消息监听器 → 转发到 STOMP
    @Bean
    MessageListenerAdapter queueEventListener() {
        return new MessageListenerAdapter((MessageListener) (message, pattern) -> {
            try {
                String channel = new String(message.getChannel(), StandardCharsets.UTF_8);
                String body = new String(message.getBody(), StandardCharsets.UTF_8);

                // ★ 反序列化 Redis Pub/Sub 消息
                Map<String, Object> data = objectMapper.readValue(body, Map.class);
                Object deptId = data.get("departmentId");
                Object docId = data.get("doctorId");

                if (deptId != null && docId != null) {
                    // ★ 转发到 STOMP 主题
                    String topic = "/topic/queue/" + deptId + "/" + docId;
                    messagingTemplate.convertAndSend(topic, data);
                    log.debug("Redis Pub/Sub bridge → STOMP topic={} event={}", 
                              topic, data.get("type"));
                }
            } catch (Exception e) {
                log.warn("Failed to bridge queue event: {}", e.getMessage());
            }
        });
    }

    // ★ Redis Pub/Sub 订阅容器
    @Bean
    RedisMessageListenerContainer queueContainer(MessageListenerAdapter queueEventListener) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(redisTemplate.getConnectionFactory());
        // ★ 订阅 channel 模式: queue:event:*
        container.addMessageListener(queueEventListener, new ChannelTopic("queue:event:*"));
        return container;
    }
}

桥接流程详解:

Redis Pub/Sub 消息到达
    ↓
MessageListener.onMessage(message, pattern)
    ↓ 解析 channel 和 body
    ↓ 反序列化 JSON → Map<String, Object>
    ↓ 提取 departmentId 和 doctorId
    ↓
SimpMessagingTemplate.convertAndSend("/topic/queue/{deptId}/{docId}", data)
    ↓
STOMP Broker 将消息推送给所有订阅该 topic 的 WebSocket 客户端
    ↓
前端收到 JSON 消息,更新 UI

3.4 前端 WebSocket 订阅示例

// 建立连接
const socket = new SockJS('http://localhost:8080/ws-stomp');
const stompClient = Stomp.over(socket);

stompClient.connect({}, function(frame) {
    // ★ 订阅指定医生的排队主题
    stompClient.subscribe('/topic/queue/' + deptId + '/' + docId, function(message) {
        const event = JSON.parse(message.body);
        console.log('收到排队事件:', event.type, event.queueNo);
        
        switch(event.type) {
            case 'SIGN_IN':   // 新患者签到 → 刷新等待列表
            case 'CALL_NEXT': // 叫号 → 更新当前呼叫 + 等待列表
            case 'RECALL':    // 重呼 → 更新当前呼叫
            case 'SKIP':      // 过号 → 移出等待列表
            case 'COMPLETE':  // 完成就诊 → 更新状态
            case 'REENTER':   // 过号重入 → 刷新等待列表
                refreshPanel(); // 刷新面板数据
                break;
        }
    });
});

四、排队状态机

4.1 Queue 状态流转

                    ┌──────────┐
         签到入队    │          │
        ──────────→ │ WAITING  │
                    │  (等待)   │
                    └────┬─────┘
                         │ 叫号 (ZPOPMIN 出队)
                         ↓
                    ┌──────────┐
              ┌────→│ CALLING  │←─── 重呼 (recall)
              │     │  (呼叫)   │
              │     └────┬─────┘
              │          │
              │    ┌─────┴──────┐
              │    ↓            ↓
              │ ┌───────┐  ┌────────┐
              │ │SKIPPED│  │ SEEING │
              │ │(过号)  │  │(就诊中) │
              │ └───┬───┘  └───┬────┘
              │     │          │
              │  过号重入    完成就诊
              │  (reenter)     │
              │     │          ↓
              │     │     ┌────────┐
              └─────┘     │  DONE  │
                          │ (完成)  │
                          └────────┘

4.2 状态与 ZSET 的关系

状态

是否在 ZSET 中

说明

WAITING

✅ 在

等待叫号,ZPOPMIN 可弹出

CALLING

❌ 不在

叫号时已 ZPOPMIN 出队

SEEING

❌ 不在

正在就诊

SKIPPED

❌ 不在

过号时从 ZSET 移除(callNext 已出队)

DONE

❌ 不在

完成就诊,兜底 ZREM

REENTER → WAITING

✅ 重新入队

过号重入,重新 ZADD


五、关键设计决策分析

5.1 为什么选择 ZSET 而不是 LIST?

维度

LIST

ZSET (本项目选择)

入队

LPUSH O(1)

ZADD O(logN)

出队

RPOP O(1)

ZPOPMIN O(logN)

优先级插入

❌ 只能头/尾插入

✅ 按 score 插入任意位置

查询排名

LINDEX O(N)

ZRANK O(logN)

查询等待人数

LLEN O(1)

ZCARD O(1)

移除指定元素

LREM O(N)

ZREM O(logN)

结论: 医院排队需要支持急诊优先、复诊插队等优先级调度,ZSET 的 score 机制是最佳选择。

5.2 为什么用 Lua 脚本而不是 WATCH/MULTI/EXEC?

维度

WATCH/MULTI/EXEC

Lua 脚本 (本项目选择)

原子性

乐观锁,冲突需重试

天然原子,无需重试

性能

冲突时多次重试

一次执行完成

复杂度

需要处理重试逻辑

代码简洁

网络

多次 RTT

一次 RTT

结论: 排队场景并发高、冲突概率大,Lua 脚本避免了乐观锁重试开销。

5.3 为什么用 Redis Pub/Sub 桥接而不是直接推送?

方案

优点

缺点

直接 SimpMessagingTemplate

简单

多实例部署时,非操作实例无法推送

Redis Pub/Sub 桥接

多实例互通

多一跳网络开销

Redis Stream

消息可持久化、可回溯

复杂度高,排队事件不需要持久化

结论: 医院系统可能多实例部署,Pub/Sub 桥接是最简单可靠的跨实例方案。


六、核心文件索引

文件

路径

职责

enqueue.lua

hospital-service/src/main/resources/lua/

签到入队 Lua 脚本

reenter_skip.lua

hospital-service/src/main/resources/lua/

过号重入 Lua 脚本

zpopmin_batch.lua

hospital-service/src/main/resources/lua/

叫号出队 Lua 脚本

SignInService.java

hospital-service/.../service/registration/

签到服务(入队 + 过号重入)

QueueService.java

hospital-service/.../service/registration/

排队调度(叫号/重呼/过号/完成)

QueueEventPublisher.java

hospital-service/.../service/queue/

Redis Pub/Sub 事件发布

RedisPubSubBridge.java

hospital-web/.../config/

Redis Pub/Sub → STOMP 桥接

WebSocketConfig.java

hospital-web/.../config/

WebSocket/STOMP 配置

RedisConfig.java

hospital-web/.../config/

Redis 序列化配置


七、学习要点总结

7.1 Redis ZSET + Lua 脚本

  1. ZSET 的 score 是优先级调度的核心 —— 通过计算不同的 score 值实现急诊优先、复诊插队、过号重入

  2. Lua 脚本保证原子性 —— ZRANGE + 计算 + ZADD 不会被其他命令打断

  3. DefaultRedisScript 加载 Lua —— @PostConstruct 时加载,避免每次执行都读取文件

  4. 全 String 序列化 —— 确保 Lua 脚本能正确处理 ZSET 的 member

  5. ZPOPMIN 原子出队 —— 比 ZRANGE + ZREM 更安全,不会出现出队失败或重复出队

7.2 WebSocket (STOMP) + Redis Pub/Sub

  1. @EnableWebSocketMessageBroker —— Spring 的 WebSocket + STOMP 一站式注解

  2. SimpleBroker + STOMP Endpoint —— 配置消息代理和连接端点

  3. SimpMessagingTemplate.convertAndSend() —— 服务端主动推送的核心 API

  4. Redis Pub/Sub 跨实例广播 —— 解决多实例部署时 WebSocket 推送问题

  5. Pub/Sub → STOMP 桥接模式 —— MessageListener 收到 Redis 消息后转发到 STOMP topic

  6. 前端 SockJS 降级 —— 浏览器不支持 WebSocket 时自动降级为轮询

7.3 架构模式

  1. 事件驱动 —— 所有操作完成后发布事件,解耦业务逻辑和推送逻辑

  2. CQRS 读写分离思想 —— 排队调度走 Redis(写),面板查询走 Redis ZRANGE + DB(读)

  3. 多实例无状态 —— 通过 Redis 共享状态,任意实例可处理请求


智慧医院排队叫号系统 —— 技术详解
https://iamold2.top//archives/zhi-hui-yi-yuan-pai-dui-jiao-hao-xi-tong-ji-shu-xiang-jie
作者
optimisticPessimist
发布于
2026年10月22日
许可协议