智慧医院排队叫号系统 —— 技术详解
基于 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 技术选型理由
1.3 核心 Redis Key 设计
二、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 (签到时间戳,自然排序) │
│ │
│ 谁先签到谁排前面,时间决定顺序 │
└─────────────────────────────────────────────────────────────────┘参数说明表:
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 状态的排队数 + 12.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}")
↓ 收到推送 → 更新 UI3.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 降级支持
}
}配置说明:
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);
}
}
}事件类型一览:
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 消息,更新 UI3.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 的关系
五、关键设计决策分析
5.1 为什么选择 ZSET 而不是 LIST?
结论: 医院排队需要支持急诊优先、复诊插队等优先级调度,ZSET 的 score 机制是最佳选择。
5.2 为什么用 Lua 脚本而不是 WATCH/MULTI/EXEC?
结论: 排队场景并发高、冲突概率大,Lua 脚本避免了乐观锁重试开销。
5.3 为什么用 Redis Pub/Sub 桥接而不是直接推送?
结论: 医院系统可能多实例部署,Pub/Sub 桥接是最简单可靠的跨实例方案。
六、核心文件索引
七、学习要点总结
7.1 Redis ZSET + Lua 脚本
ZSET 的 score 是优先级调度的核心 —— 通过计算不同的 score 值实现急诊优先、复诊插队、过号重入
Lua 脚本保证原子性 ——
ZRANGE + 计算 + ZADD不会被其他命令打断DefaultRedisScript加载 Lua ——@PostConstruct时加载,避免每次执行都读取文件全 String 序列化 —— 确保 Lua 脚本能正确处理 ZSET 的 member
ZPOPMIN 原子出队 —— 比
ZRANGE + ZREM更安全,不会出现出队失败或重复出队
7.2 WebSocket (STOMP) + Redis Pub/Sub
@EnableWebSocketMessageBroker—— Spring 的 WebSocket + STOMP 一站式注解SimpleBroker + STOMP Endpoint —— 配置消息代理和连接端点
SimpMessagingTemplate.convertAndSend()—— 服务端主动推送的核心 APIRedis Pub/Sub 跨实例广播 —— 解决多实例部署时 WebSocket 推送问题
Pub/Sub → STOMP 桥接模式 ——
MessageListener收到 Redis 消息后转发到 STOMP topic前端 SockJS 降级 —— 浏览器不支持 WebSocket 时自动降级为轮询
7.3 架构模式
事件驱动 —— 所有操作完成后发布事件,解耦业务逻辑和推送逻辑
CQRS 读写分离思想 —— 排队调度走 Redis(写),面板查询走 Redis ZRANGE + DB(读)
多实例无状态 —— 通过 Redis 共享状态,任意实例可处理请求