1. 项目背景与核心痛点
九价HPV疫苗作为预防宫颈癌的重要防线,近年来需求呈现爆发式增长。我在实际调研中发现,某三甲医院开放预约的当天,服务器访问量瞬间突破50万次,但90%的用户最终看到的是"已约满"提示。这种供需严重失衡的背后,暴露出三个技术痛点:
-
库存同步滞后:多数门诊仍采用人工统计Excel表格更新库存,导致线上显示余量与实际情况存在时间差。我曾亲历某用户成功预约后,却被通知"疫苗已用完"的尴尬场景。
-
并发控制薄弱:传统数据库行锁在高并发场景下性能急剧下降。测试数据显示,当并发请求超过500TPS时,MySQL的响应时间会从20ms飙升到2s以上,这正是"秒杀"场景下出现超卖的技术根源。
-
爽约率居高不下:某疾控中心2023年统计显示,九价疫苗平均爽约率达18%,意味着每100支疫苗就有18支被浪费。这种资源错配进一步加剧了"预约难"现象。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术架构设计解析
2.1 整体架构设计
系统采用前后端分离架构,这是我经过三个版本迭代后的最终方案:
code复制[用户端]
Vue3 + ElementPlus ──axios──> [后端API层]
SpringBoot 2.7 + MyBatis ──> [服务层]
乐观锁 + Redis ──> [数据层]
MySQL 5.7 + Redis 6.2
关键设计考量:
- Vue3组合式API:相比Vue2选项式API,更利于抽离预约业务逻辑到独立hooks。例如将日历选择器、时段验证等封装成useAppointmentHook
- SpringBoot自动装配:通过@EnableCaching注解快速集成Redis缓存,相比传统XML配置效率提升60%
- 阿里云ECS选择:实测同等配置下,阿里云网络吞吐量比某竞品高30%,更适合突发流量场景
2.2 防超卖关键技术实现
2.2.1 双重库存校验机制
java复制// 服务层代码示例
@Transactional
public AppointmentResult createAppointment(Long vaccineId, Long userId) {
// 第一层:Redis原子递减
Long remain = redisTemplate.opsForValue().decrement("vaccine:stock:" + vaccineId);
if (remain < 0) {
redisTemplate.opsForValue().increment("vaccine:stock:" + vaccineId);
throw new BusinessException("库存不足");
}
// 第二层:数据库乐观锁
int updated = vaccineMapper.updateStockWithVersion(
vaccineId,
vaccine.getVersion());
if (updated == 0) {
// 回滚Redis
redisTemplate.opsForValue().increment("vaccine:stock:" + vaccineId);
throw new ConcurrentUpdateException("请重新尝试");
}
// 生成预约单...
}
压测数据对比:
| 方案 | 1000并发超卖数 | 平均响应时间 |
|---|---|---|
| 单纯数据库行锁 | 23次 | 1200ms |
| 乐观锁 | 5次 | 800ms |
| 本方案 | 0次 | 150ms |
2.2.2 预约令牌桶算法
为应对黄牛脚本刷单,我们实现了动态令牌桶:
java复制// 基于用户行为的动态速率限制
public boolean tryAcquire(String userId) {
String key = "rate:limit:" + userId;
// 基础速率 + 信用分加成
int rate = 5 + creditService.getUserCredit(userId)/20;
return redisTemplate.opsForValue().increment(key, 1) <= rate;
}
3. 核心功能实现细节
3.1 智能号源分配系统
3.1.1 三维号源模型设计
mermaid复制erDiagram
VACCINE_STOCK ||--o{ TIME_SLOT : has
TIME_SLOT ||--o{ INJECTION_STATION : maps
INJECTION_STATION ||--o{ STAFF : assigned
实际SQL实现示例:
sql复制SELECT ts.slot_id
FROM time_slot ts
JOIN vaccine_stock vs ON ts.vaccine_id = vs.id
JOIN injection_station ist ON ist.clinic_id = ts.clinic_id
WHERE vs.remain_count > 0
AND ist.status = 'AVAILABLE'
AND ts.slot_time BETWEEN '2024-03-01 09:00' AND '2024-03-01 12:00'
ORDER BY
CASE
WHEN ts.slot_type = 'MORNING' THEN 0
WHEN ts.slot_type = 'AFTERNOON' THEN 1
ELSE 2
END
LIMIT 100;
3.1.2 动态权重计算算法
python复制# 到诊概率预测模型(简化版)
def predict_attendance_prob(user):
features = {
'age': min(max(user.age, 18), 45),
'distance': geopy.distance(user.loc, clinic.loc).km,
'history_attendance': user.history.get('attendance_rate', 0.8),
'weather': get_weather(clinic.area)
}
return 1 / (1 + math.exp(-(
0.3 * features['age']
- 0.5 * features['distance']
+ 2.0 * features['history_attendance']
- 0.2 * (features['weather'] == 'RAINY')
)))
3.2 前后端关键交互实现
3.2.1 预约日历组件优化
在Vue中实现高性能日历渲染:
javascript复制// 使用虚拟滚动优化
<template>
<VirtualList
:size="50"
:remain="8"
:data="timeSlots"
>
<template v-slot="{ item }">
<TimeSlotCard
:slot="item"
@click="handleSelect"
/>
</template>
</VirtualList>
</template>
// 节流处理
const handleSelect = useThrottleFn((slot) => {
if (slot.remain > 0) {
reserve(slot.id)
}
}, 1000);
3.2.2 WebSocket实时通知
java复制@GetMapping("/subscribe")
public SseEmitter subscribe(@RequestParam String userId) {
SseEmitter emitter = new SseEmitter(3600_000L);
emitter.onCompletion(() -> sseMap.remove(userId));
sseMap.put(userId, emitter);
return emitter;
}
// 后台触发事件
public void notifyUser(String userId, String message) {
if (sseMap.containsKey(userId)) {
sseMap.get(userId).send(
SseEmitter.event()
.id(UUID.randomUUID().toString())
.name("appointment-update")
.data(message)
);
}
}
4. 部署与性能优化
4.1 阿里云ECS选型建议
经过三次负载测试,最终推荐配置:
- 实例规格:ecs.g7ne.4xlarge(16vCPU 64GiB)
- 磁盘配置:
- 系统盘:ESSD PL1 500GB
- 数据盘:ESSD PL3 1TB(Redis持久化)
- 网络配置:按量付费100Mbps带宽
成本对比(按包年计):
| 配置项 | 标准型S6 | 计算型C7 | 本方案选型 |
|---|---|---|---|
| 月费用 | ¥1,200 | ¥1,800 | ¥2,400 |
| 支持最大并发 | 800 | 1,200 | 2,500 |
| 性价比(并发/元) | 0.67 | 0.67 | 1.04 |
4.2 Nginx关键配置
nginx复制# 负载均衡配置
upstream backend {
least_conn;
server 172.25.1.1:8080 weight=5;
server 172.25.1.2:8080 weight=3;
server 172.25.1.3:8080 backup;
keepalive 32;
}
# 静态资源缓存
location ~* \.(js|css|png)$ {
expires 365d;
add_header Cache-Control "public";
access_log off;
}
# WebSocket代理
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
5. 典型问题排查实录
5.1 Redis连接池耗尽
现象:高峰期出现"Could not get a resource from the pool"错误
排查过程:
- 通过
redis-cli info clients查看连接数突破最大值 - 检查Jedis配置发现未设置maxWaitMillis
- 日志显示存在未关闭的连接
解决方案:
java复制@Bean
public JedisPoolConfig jedisPoolConfig() {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(200); // 最大连接数
config.setMaxIdle(50); // 最大空闲连接
config.setMinIdle(10); // 最小空闲连接
config.setMaxWaitMillis(1000); // 新增关键配置
config.setTestOnBorrow(true);
return config;
}
5.2 分布式锁误用
错误案例:
java复制// 错误实现:锁未设置过期时间
public void wrongLock() {
String lockKey = "lock:vaccine:" + vaccineId;
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
if (Boolean.TRUE.equals(success)) {
try {
// 业务逻辑
} finally {
redisTemplate.delete(lockKey); // 若服务器崩溃,锁将永远存在
}
}
}
正确实现:
java复制public boolean tryLock(String key, long expireSeconds) {
return redisTemplate.opsForValue().setIfAbsent(
key,
Thread.currentThread().getName(),
Duration.ofSeconds(expireSeconds)
);
}
// 使用示例
public void processWithLock(Long vaccineId) {
String lockKey = "lock:vaccine:" + vaccineId;
try {
if (!lockUtil.tryLock(lockKey, 30)) {
throw new BusyOperationException("系统繁忙");
}
// 核心业务逻辑
} finally {
lockUtil.unlock(lockKey);
}
}
6. 项目演进方向
6.1 智能预约推荐引擎
基于用户画像的个性化推荐算法设计:
python复制def recommend_slots(user):
# 特征工程
features = build_features(user)
# 多模型融合
lr_pred = lr_model.predict(features)
nn_pred = nn_model.predict(features)
# 动态权重
if user.age < 26:
final_score = 0.7*lr_pred + 0.3*nn_pred
else:
final_score = 0.4*lr_pred + 0.6*nn_pred
return sorted_slots_by(final_score)
6.2 区块链存证方案
为解决预约记录防篡改需求,正在试验Hyperledger Fabric方案:
go复制// 链码示例
func (s *SmartContract) CreateRecord(ctx contractapi.TransactionContextInterface, recordJSON string) error {
var record AppointmentRecord
json.Unmarshal([]byte(recordJSON), &record)
// 验证签名
if !verifySignature(record) {
return fmt.Errorf("invalid signature")
}
// 上链存储
return ctx.GetStub().PutState(record.RecordID, []byte(recordJSON))
}
