ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

疫苗预约系统实战:高并发库存一致性与权限设计

疫苗预约系统实战:高并发库存一致性与权限设计

1. 项目概述与整体设计思路

做企业级应用和做课程设计完全是两码事,这个疫苗发布和接种预约系统就是典型的“看着简单,做起来全是细节”的项目。表面上是管理疫苗信息、用户在线预约、后台审核接种记录,但真正往企业级靠的时候,你要面对的是高并发预约、库存一致性、状态流转控制、权限分级这些硬骨头。我最初接到这个项目的时候,第一反应不是急着建表和写接口,而是先想清楚一个事情:这个系统的核心业务到底是谁在用、怎么用。

这套系统的使用方其实有三类角色:系统管理员负责维护疫苗品类、发布接种计划、审核数据;医护人员(或接种点工作人员)需要查看预约队列、录入接种结果;普通用户则在微信端或Web端完成实名注册、查看可预约疫苗、选择时间段、获取预约凭证。三类角色的操作路径完全不同,对数据的读写需求也完全不同,这就要求系统在架构上必须把权限和业务边界拆干净。

技术选型上,项目采用了SpringBoot + Vue + MyBatis + MySQL这套组合。这个选型在今天看来可能不算“最前沿”,但它是目前企业级Java后端最稳妥、人才储备最充足、踩坑成本最低的组合之一。SpringBoot负责提供成熟的依赖管理和自动化配置,让团队不用花大量时间去搭建基础框架;MyBatis作为持久层框架,胜在SQL可控性强,业务复杂的查询可以手写SQL来优化,这对预约系统这种需要频繁联表查询、条件筛选的业务特别友好;MySQL则承载核心业务数据,配合合理索引和事务隔离级别,完全能支撑中小型城市级别的疫苗接种预约量。

为什么不用MyBatis-Plus全自动CRUD?也不是不行,但在这个项目里,预约逻辑、库存扣减、状态流转都涉及多表联动和条件更新,手写SQL反而更容易控制细节,排查问题也直观。至于前端为什么选Vue而不是React,很大程度是因为Vue对后端开发者更友好,模板语法接近HTML思维,团队上手快,生态里的Element UI组件库做后台管理界面是一把好手。

需要说明的是,项目是完整可运行的源码版本,包含数据库初始化脚本、后端工程、前端工程和部署文档。我在这篇文章里会拆解整个系统的核心设计思路和实现细节,包括数据库表结构、后端关键接口、前端页面逻辑、以及我实际部署过程中踩过的坑。

2. 需求拆解与核心功能模块设计

2.1 业务角色与权限边界

先讲权限。企业级系统里权限设计不是简单的前端按钮显隐,而是后端接口级别的硬隔离。我把整个系统的用户分为三类:管理员(ROLE_ADMIN)、接种点工作人员(ROLE_STAFF)、普通用户(ROLE_USER)。

三者权限边界如下:

角色核心权限典型操作
管理员全局管理疫苗品类增删改查、接种点管理、公告发布、数据统计
工作人员接种点运营查看本接种点预约列表、核销预约码、录入接种结果
普通用户个人操作注册登录、查询疫苗、预约登记、查看个人接种记录

权限控制用Spring Security + JWT实现,后端通过注解@PreAuthorize("hasRole('ADMIN')")在接口层做拦截。这里有一个很容易被新手忽略的点:前端路由守卫只是用户体验层面的优化,真正的安全边界必须在后端接口上。哪怕前端把某个按钮藏起来了,别人直接调接口一样能操作,所以后端权限校验必须是最后一道闸门,永远不能信任前端传过来的任何角色信息。

2.2 疫苗发布与库存管理

疫苗发布是整个系统的上游数据源,管理员在后台创建疫苗批次,包含疫苗名称、生产企业、批次号、接种剂次(第1针/第2针/加强针)、适用年龄范围、禁忌症说明、库存数量、有效期等字段。一个批次对应一个具体的库存池,预约时扣减的就是批次库存。

这里有个业务细节要特别注意:疫苗不同于普通商品,同一款疫苗可能有多个批次,不同批次的生产日期和有效期不一样。用户在前端看到的是“可预约疫苗列表”,但后台实际库存必须精确到批次。所以我在设计时把vaccine(疫苗品类)和vaccine_batch(疫苗批次)拆成两张表,品类表放通用信息,批次表放库存和有效期,预约单关联的是批次ID而不是品类ID。这样一来,批次过期自动下架、批次库存预警、特定批次召回这些后续需求都能平滑扩展。

库存管理上我用了一个简单但有效的策略:数据库字段stock记录剩余可预约数量,每次预约在事务内执行UPDATE vaccine_batch SET stock = stock - 1 WHERE id = ? AND stock > 0,利用数据库行锁天然解决并发超卖问题。这个方法比“先SELECT再UPDATE”的方式更安全——因为查询和更新不是原子操作,并发场景下会出现多个请求同时读到stock=1,然后都执行更新,把库存扣成负数。而把条件直接写在UPDATE语句里,数据库会在行级别加锁,只有一个事务能更新成功。

2.3 预约流程与状态机设计

预约是整个系统的核心流程,也是最容易出并发问题的环节。我画了一条完整的状态流转链:

待支付(仅限收费疫苗)→ 已预约 → 已核销 → 已完成 ↘ 已取消(用户主动取消或超时未接种)

考虑到疫苗预约的特性,大多数情况下是免费接种,所以简化后的流程是:用户选择接种点和时间段 → 系统校验批次库存和预约名额 → 创建预约记录并扣减库存 → 生成预约二维码 → 用户到现场出示二维码 → 工作人员核销 → 录入接种信息。

这里我设计了一个状态机字段appointment_status,用整数表示状态值:0-已取消,1-待接种,2-已核销,3-已完成。所有状态变更都通过后端接口完成,前端只负责展示,不允许直接修改状态。

一个容易踩坑的地方:核销操作必须做幂等控制。如果工作人员手抖点了两次核销,或者前端重复提交请求,同一预约单就被核销两次,可能导致同一个二维码被使用多次。我的解决办法是在核销接口加唯一约束判断,核销前先检查当前状态是否为“待接种”,如果不是直接返回错误。另外在预约单表上加了一个version字段做乐观锁,更新时带上WHERE version = ?,防止并发更新导致状态覆盖。

2.4 时间段预约与名额控制

接种点每天能承接的接种人数不是无限的,必须配置每日名额上限和分时段名额。我把时间段的粒度设置成半小时一个槽位,例如9:00-9:30、9:30-10:00,每个槽位可预约人数由接种点管理员自行配置。

预约时,后端先检查该时间段当前已预约人数是否小于上限,再执行库存扣减。这两个动作必须放在同一个事务里,确保数据一致。我写了一个带有SELECT ... FOR UPDATE的查询来判断名额,虽然在高并发下会对该时间段的行加锁,但实际效果很好——不会出现同一个时间段预约人数超上限的情况。

在实际压测中,这种设计在单机部署、1000并发左右的情况下表现稳定。如果未来要支撑更大流量,可以在应用层加分布式锁(如Redis),或者在上游加一个MQ做削峰填谷,但就现阶段项目的定位来说,数据库乐观锁 + 条件更新已经够用。

3. 数据库设计与核心表结构解析

数据库设计决定了整个系统能走多远。这一节我把核心表的字段和设计思路都列出来,这部分的经验可以直接迁移到其他预约类系统(比如医院挂号、政务办事预约)上。

3.1 用户表与扩展信息

用户表我拆成了两部分:sys_user存放登录认证所需的基础账号信息,user_profile存放业务扩展信息(姓名、身份证号、联系电话、过敏史等)。拆分的原因是登录信息和业务信息变更频率差别很大,拆开之后可以独立优化索引、独立扩展字段。

sys_user关键字段如下:

字段名类型说明
idbigint主键,自增
usernamevarchar(50)登录名,唯一索引
passwordvarchar(100)BCrypt加密后的密码
real_namevarchar(20)真实姓名
phonevarchar(20)手机号
rolevarchar(20)角色编码
statustinyint账号状态,0-禁用 1-正常
create_timedatetime创建时间

身份证号没有直接存在用户主表里,而是放在user_profile里单独管理,并且渲染到前端时做了脱敏处理,只显示前6位和后4位。这个细节是考虑到数据合规要求,虽然疫苗预约不属于严格意义上的金融医疗核心数据,但养成脱敏习惯总没错。

3.2 疫苗品类表与批次表

这里要理解一个业务差异:用户关心的是“打什么疫苗、什么品牌、几针”,而系统库存管理关心的是“哪一批次的疫苗还有多少支”。所以两张表各司其职。

vaccine表存放疫苗品类信息:

  • id、vaccine_name(疫苗名称)、manufacturer(生产企业)、vaccine_type(疫苗类型)、dosage_form(剂型)、age_min/age_max(适用年龄范围)、description(描述)、status(上架/下架)

vaccine_batch表存放批次库存:

  • id、vaccine_id(关联品类表)、batch_no(批次号)、production_date(生产日期)、expire_date(有效期至)、stock(剩余库存)、total_stock(初始库存)、status(0-可用 1-已用完 2-已过期)

批次过期检测我写了一个定时任务,每天凌晨扫描一次expire_date,把已过期的批次自动置为不可预约状态。这个任务用Spring的@Scheduled注解就能实现,配合cron = "0 0 2 * * ?"每天凌晨2点执行。

3.3 预约单表与并发控制字段

appointment表是整个系统的核心交易表,字段设计直接影响后续统计和运维:

字段名类型说明
idbigint主键
order_novarchar(32)预约单号,全局唯一
user_idbigint用户ID
batch_idbigint疫苗批次ID
point_idbigint接种点ID
appoint_datedate预约日期
time_slotvarchar(20)时间段,如09:00-09:30
statustinyint状态:0-取消 1-待接种 2-已核销 3-已完成
versionint乐观锁版本号
cancel_reasonvarchar(200)取消原因
create_timedatetime创建时间

order_no我采用了“日期 + 随机数 + 自增ID”的组合方式,例如AP202506150001。没有直接用UUID当订单号,因为UUID太长且无序,用户报给工作人员核销时不方便,数据库索引效率也低。实际生成时用Redis或在Java代码里生成,保证唯一即可。

3.4 接种点表和核销记录表

vaccination_point表管理线下接种点,包括接种点名称、地址、联系电话、每日最大接待量、状态(营业/停业)。每个接种点可以配置多个时间段名额,这部分我单独建了point_slot_config表,存接种点ID、时间段、可预约人数上限、已预约人数。

vaccination_record表记录实际接种结果,由工作人员在核销后填写:关联预约单ID、疫苗批次ID、接种医生、接种部位、接种时间、不良反应备注等。这张表和预约表是一对一关系,但单独拆出来是因为查询频率不同——记录表主要用于事后追溯和统计报表,和预约流程的实时读写分开能减少锁竞争。

4. 后端SpringBoot + MyBatis核心实现

后端工程按标准的分层结构组织:controller→service→mapper,共用entity、dto、vo、common、config、security等包。这里重点讲几个核心实现,都是能直接拿来用的。

4.1 JWT认证与Spring Security整合

登录认证用的是JWT(JSON Web Token),无状态、跨域友好。用户登录成功后,后端签发一个有效期2小时的Token返回前端,前端存储在本地并放到请求头的Authorization字段中。

核心配置类继承WebSecurityConfigurerAdapter,重写configure(HttpSecurity http)方法:

http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/auth/**", "/api/vaccine/list", "/api/appointment/public/**").permitAll() .antMatchers("/api/admin/**").hasRole("ADMIN") .antMatchers("/api/staff/**").hasRole("STAFF") .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

注意几个细节:CSRF必须关闭,因为JWT方案不依赖Cookie会话,CSRF防护意义不大且会干扰跨域请求;设置了STATELESS会话策略,服务端不存Session,完全靠Token认证,这样才能支持前后端分离部署。

JWT过滤器里做的事很简单:取出请求头中的Token → 解析用户信息 → 构造Authentication对象放入SecurityContext。注意解析失败时不要直接抛异常,而是放行让后续逻辑自己处理,避免给攻击者提供接口探测的信息。

4.2 预约接口的事务控制与防超卖实现

预约接口是整个系统的技术难点,实现好不好直接决定系统能不能上生产环境。核心代码如下:

@Transactional(rollbackFor = Exception.class) public AppointmentDTO createAppointment(Long userId, Long batchId, Long pointId, String appointDate, String timeSlot) { // 1. 校验疫苗批次状态和库存 VaccineBatch batch = vaccineBatchMapper.selectByIdForUpdate(batchId); if (batch == null || batch.getStatus() != 0 || batch.getStock() <= 0) { throw new BusinessException("该批次疫苗不可预约"); } // 2. 校验时间段名额 PointSlotConfig slot = pointSlotMapper.selectByPointAndTime(pointId, appointDate, timeSlot); if (slot == null || slot.getBookedCount() >= slot.getMaxCount()) { throw new BusinessException("该时间段已约满"); } // 3. 生成预约单号 String orderNo = generateOrderNo(); // 4. 插入预约记录 Appointment appointment = new Appointment(); appointment.setOrderNo(orderNo); appointment.setUserId(userId); appointment.setBatchId(batchId); appointment.setPointId(pointId); appointment.setAppointDate(appointDate); appointment.setTimeSlot(timeSlot); appointment.setStatus(1); appointment.setVersion(1); appointmentMapper.insert(appointment); // 5. 扣减批次库存(条件更新防止超卖) int rows = vaccineBatchMapper.decreaseStock(batchId, 1); if (rows == 0) { throw new BusinessException("手慢了,库存已被抢完"); } // 6. 时间段已预约人数 +1 pointSlotMapper.increaseBookedCount(slot.getId()); return buildDTO(appointment); }

关键点是第5步的decreaseStockSQL:

UPDATE vaccine_batch SET stock = stock - 1 WHERE id = #{batchId} AND stock > 0

这条SQL利用数据库的行锁和条件更新,在单条语句里完成了“检查库存 > 0”和“扣减1”两个操作,天然避免了并发超卖。执行后返回受影响行数,如果为0说明库存已经被扣完了,直接抛异常回滚事务,整个预约操作一并撤销。

还有一个隐含问题:等待时间段的并发预约。如果100个人同时预约同一个时间段的最后一个名额,数据库会怎样?pointSlotMapper.increaseBookedCount这条更新语句执行时会锁定对应行,其他事务的更新会排队等待,所以最终只有1个请求成功,其余全部拿到bookedCount >= maxCount的校验异常。这就是数据库行锁的串行化效果,在最坏情况下性能会有所下降,但数据一致性绝对有保障。

4.3 MyBatis多表查询与XML映射

MyBatis的SQL写在XML文件里,虽然多写几个标签,但对复杂查询的可维护性提升很大。以“查询用户预约列表(含疫苗信息和接种点信息)”为例:

<select id="selectAppointmentDetail" resultType="com.example.vo.AppointmentDetailVO"> SELECT a.id, a.order_no, a.appoint_date, a.time_slot, a.status, a.create_time, v.vaccine_name, v.manufacturer, v.vaccine_type, vb.batch_no, p.point_name, p.address FROM appointment a INNER JOIN vaccine_batch vb ON a.batch_id = vb.id INNER JOIN vaccine v ON vb.vaccine_id = v.id INNER JOIN vaccination_point p ON a.point_id = p.id WHERE a.user_id = #{userId} ORDER BY a.create_time DESC </select>

三张表联查用INNER JOIN就够了,因为预约单在创建时就已确保关联的批次和接种点存在。如果有些历史数据可能指向已删除的批次,那就改用LEFT JOIN,具体业务具体分析。

这里我踩过一个坑:resultType映射下划线字段到驼峰属性需要在配置里开启。在application.yml里加:

mybatis: configuration: map-underscore-to-camel-case: true

开启之后,数据库的order_no就能自动映射到Java属性的orderNo,不需要在XML里逐个写resultMap。如果不开启,查出来的对象里这些字段全是null,排查半天才发现是映射问题。

4.4 定时任务与数据统计

系统需要两个定时任务:批次过期检测(前面提到过)和每日预约数据汇总统计。都用@Scheduled注解实现,简单可靠:

@Component public class VaccineBatchTask { @Scheduled(cron = "0 0 2 * * ?") public void expireBatchCheck() { List<VaccineBatch> batchList = vaccineBatchMapper.selectUnExpired(); LocalDate today = LocalDate.now(); for (VaccineBatch batch : batchList) { if (batch.getExpireDate().isBefore(today)) { vaccineBatchMapper.updateStatus(batch.getId(), 2); log.info("批次 {} 已过期,自动下架", batch.getBatchNo()); } } } }

数据统计方面,后台管理首页要展示“今日预约量”“本月接种量”“各接种点预约饱和度”等指标。这些数据如果实时查数据库,虽然数据量不大问题也不大,但更合理的做法是建一张daily_statistics汇总表,定时任务每天凌晨把前一天的预约、核销数据聚合写入,前端查询直接读统计表。这样既减轻主表查询压力,也方便后续做趋势分析。

5. 前端Vue实现与页面交互要点

前端工程基于Vue 2 + Element UI + Axios + Vue Router构建。为什么选Vue 2而不是Vue 3?因为项目启动时Vue 3生态还不够成熟,Element UI对Vue 2的支持最完善,团队也最熟悉。如果是新项目,Vue 3 + Element Plus也是不错的选择,但代码写法上会有差异,需要团队统一评估。

5.1 前端项目结构和路由设计

前端目录结构如下:

src/ ├── api/ // 接口请求封装,按模块拆分 │ ├── auth.js │ ├── vaccine.js │ ├── appointment.js │ └── user.js ├── assets/ // 静态资源 ├── components/ // 公共组件 ├── router/ // 路由配置 ├── store/ // Vuex状态管理 ├── views/ // 页面组件 │ ├── admin/ // 管理后台页面 │ ├── staff/ // 工作人员页面 │ └── user/ // 用户端页面 └── utils/ // 工具函数,含axios实例封装

路由配置采用动态路由 + 静态路由结合的方式。登录后根据角色动态添加对应权限路由,避免用户直接访问无权限页面。比如普通用户登录后,路由表里只有首页、疫苗列表、我的预约;管理员登录后多出用户管理、疫苗管理、接种点管理等路由。

5.2 Axios拦截器与Token刷新机制

Axios实例封装是前端的核心基建,我在utils/request.js里做了两层拦截:

请求拦截器:从localStorage取出Token,放到请求头Authorization: Bearer <token>,这样后端就能识别用户身份。

响应拦截器:统一处理HTTP状态码。401表示Token失效,我做了自动跳转登录页并清除本地用户信息的处理;403表示无权限,提示“当前账号无权操作”;其他业务错误码(后端在响应体里返回code字段)统一弹Message提示。

这里有一个很多新手容易忽略的点:Token过期后的体验优化。如果用户正在填写预约表单,填写到一半Token过期,点击提交直接被踢回登录页,体验非常糟糕。我的处理方式是在响应拦截器里判断401时,把当前路由地址存到sessionStorage,登录成功后跳回原页面。虽然不能解决表单数据丢失的问题,但至少用户能快速回到原来的功能入口。

5.3 预约页面的核心交互逻辑

用户端的预约页面是整个前端最复杂的部分,交互链路是:选择疫苗 → 选择接种点 → 选择日期和时间段 → 确认预约。

疫苗列表用卡片式展示,每张卡片上显示疫苗名称、生产企业、剩余库存和“立即预约”按钮。库存为0或批次已过期的疫苗,按钮置灰不可点击。这里的库存数据是通过/api/vaccine/list接口查询的实时数据,不是页面写死的。

选择接种点和时间段时,我用了Element UI的DatePicker + 自定义时间段卡片组件。日期选择器禁用了今天之前的日期,时间段根据后端返回的point_slot_config动态渲染。已经约满的时间段显示“已约满”并禁止选中,可预约的显示剩余名额。

用户点击“确认预约”后,前端先弹出确认框展示预约信息摘要,确认后调用后端接口。后端返回成功时,前端跳转到“预约成功”页面,显示预约单号和预约二维码。二维码用qrcode前端库生成,内容就是一个包含预约单号的链接,工作人员扫码后跳转到核销页面。

6. 部署上线与常见问题排查实战

这一节是实践经验的总结。项目做完只是第一步,真正把它部署到服务器上、跑起来不出问题,才是企业级应用的目标。

6.1 环境准备与前后端构建

后端部署的服务器环境是Linux(CentOS 7.9),需要提前安装好:

  • JDK 1.8(SpringBoot 2.x推荐Java 8)
  • MySQL 5.7(使用时区设置要特别注意,后面讲)
  • Nginx 1.20(用于前端静态资源托管和反向代理)
  • Maven 3.6+(用于构建后端jar包)

后端打包命令:

mvn clean package -DskipTests

打包后在target/目录下生成vaccine-system-1.0.0.jar,上传到服务器后使用:

nohup java -jar vaccine-system-1.0.0.jar --spring.profiles.active=prod > /app/logs/vaccine.log 2>&1 &

--spring.profiles.active=prod指定生产环境配置文件,生产环境的数据源密码、Redis地址等敏感信息单独维护在application-prod.yml里,和开发环境隔离。

前端打包:

npm install npm run build

生成的dist/目录通过Nginx托管。Nginx配置里要做两件事:静态文件服务 + 反向代理。核心配置如下:

server { listen 80; server_name vaccine.example.com; # 前端静态资源 location / { root /app/vaccine-front/dist; index index.html; try_files $uri $uri/ /index.html; # 支持Vue Router的history模式 } # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

try_files那一行特别重要,Vue Router如果使用history模式,刷新页面时Nginx需要把所有路径都回退到index.html,否则刷新二级页面会报404。

6.2 部署过程中的常见错误与解决方案

我把部署中遇到的高频问题整理成一张速查表,这些问题基本都是新手容易卡住的地方:

问题现象可能原因解决方案
数据库连接报Public Key Retrieval is not allowedMySQL 8.0默认认证插件和连接驱动不匹配JDBC连接串加allowPublicKeyRetrieval=true,或改用caching_sha2_password插件
前端访问API报跨域错误前后端分离部署,浏览器跨域拦截优先用Nginx反向代理解决(Nginx层面同源),或后端配置CORS过滤器
中文乱码数据库字符集不是UTF-8建库时指定CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,并在JDBC连接串加characterEncoding=utf-8
打包时Maven下载依赖超时网络问题或镜像源不稳定在settings.xml配置阿里云Maven镜像
定时任务不执行SpringBoot主类缺@EnableScheduling主启动类加上该注解
预约时一直报“时间段已约满”数据库时间字段类型与Java LocalDate映射错误在MyBatis配置里注册LocalDateTypeHandler,或升级MyBatis版本

这里重点讲一下MySQL 8.0的坑。MySQL 8默认使用caching_sha2_password认证插件,而老版本的JDBC驱动(如5.1.6以下)不认识这个插件,就会报错。解决方法是升级MySQL Connector/J到8.0版本,同时在连接串上加两个参数:

jdbc:mysql://localhost:3306/vaccine_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&characterEncoding=utf8

serverTimezone也必须显式指定,否则半夜跑定时任务时日期可能差8小时,导致批次过期判断错乱。

6.3 性能优化与并发压测实录

系统上线前我做了一轮并发压测,用JMeter模拟300个用户同时抢同一批疫苗的预约名额。压测结果:300个并发请求,成功预约208个,其余92个全部返回“库存不足”或“已约满”的业务提示,无超卖现象,无异常报错,平均响应时间从初始的2.3秒下降到优化后的0.6秒。

优化措施主要做了三个:

第一,热点数据缓存。疫苗列表属于读多写少的数据,用Redis缓存疫苗列表30秒,减少数据库QPS。只有管理员上下架疫苗或批次变化时才主动刷新缓存。

第二,CDN加速静态资源。前端的JS、CSS打包文件放在CDN上,Nginx只负责HTML入口和API代理,静态资源加载速度提升明显。

第三,SQL索引优化。预约查询、时间段统计都建了联合索引。比如appointment表上建了(user_id, appoint_date)联合索引、(point_id, appoint_date, time_slot)联合索引,配合EXPLAIN查看执行计划,确保没有全表扫描。

压测还暴露了一个问题:单台服务器上,数据库的行锁等待会导致部分请求超时。所以部署时我把数据库连接池参数调整了一下,maximum-pool-size从默认10调整到50,connection-timeout设为3000毫秒,缓解了高并发下的连接池打满问题。

6.4 数据备份与应急预案

企业级应用最后必须考虑的数据安全。我配置了两层机制:

自动备份:每天凌晨3点用mysqldump备份数据库,备份文件保留最近7天,脚本如下:

#!/bin/bash BACKUP_DIR=/data/backup/mysql DATE=$(date +%Y%m%d%H%M) mysqldump -uroot -pYourPassword vaccine_db > $BACKUP_DIR/vaccine_db_$DATE.sql find $BACKUP_DIR -mtime +7 -name "*.sql" -exec rm -f {} \;

配合crontab每天执行。

手工快照:每次上线新版本前,用云平台制作数据库快照,万一导出数据时误操作或迁移脚本有问题,还能回滚到上一个稳定版本。这个步骤看起来繁琐,但真正出过事故的人都知道这是救命稻草。

7. 项目源码结构与二次开发建议

源码的目录结构直接影响接手人的上手速度。我梳理一遍当前项目的完整工程结构,这部分可以让拿到源码的开发者少走弯路。

前端工程根部:

vaccine-front/ ├── public/ ├── src/ │ ├── api/ │ ├── router/ │ ├── store/ │ ├── views/ │ └── main.js ├── package.json ├── vue.config.js └── .env.production

后端工程根部:

vaccine-system/ ├── src/main/java/com/example/vaccine/ │ ├── controller/ // 接口层,只做参数校验和返回 │ ├── service/ // 业务层,事务控制核心 │ ├── mapper/ // MyBatis接口 │ ├── entity/ // 数据库实体 │ ├── dto/ // 接收前端参数对象 │ ├── vo/ // 返回前端视图对象 │ ├── common/ // 全局异常、统一返回、工具类 │ ├── config/ // 配置类(Security、Redis、Swagger等) │ └── task/ // 定时任务 ├── src/main/resources/ │ ├── mapper/ // MyBatis XML文件 │ ├── application.yml │ └── application-prod.yml ├── pom.xml └── sql/ ├── schema.sql // 建表语句 └── data.sql // 初始化数据

拿到源码后第一件事不是急着启动,而是先跑通数据库初始化脚本。sql/目录下的schema.sql按依赖顺序建库建表,data.sql里预置了管理员账号和几个测试疫苗批次。启动后端前修改application-dev.yml里的数据库连接配置,确认数据库能连通再启动。

二次开发建议方面,我推荐几个方向:

接入消息推送:预约成功和接种前一天给用户发短信或微信通知。可以在预约成功接口后接一个MQ,发送方把消息丢进队列,消费者负责调第三方短信接口,避免预约接口被短信服务拖慢。

引入Redis分布式锁:目前防超卖依赖数据库行锁,已经是单机版最稳的方案。如果部署多实例,需要考虑分布式锁——用Redisson的RLock或Redis的SET NX实现,锁的粒度是“批次ID + 时间段”。

增加报表可视化:管理后台目前的数据统计是表格形式,可以用ECharts把预约趋势、疫苗消耗、各接种点饱和度做成折线图、饼图、热力图,让管理员一眼看清全局。

对接疫苗接种电子档案:如果后续要和本地疾控系统对接,需要把vaccination_record表的数据通过接口推送出去。建议提前在记录表里预留external_id字段,存第三方系统的对应ID,方便对账和追溯。

我自己做完这个项目最大的体会是:疫苗预约系统的核心不是“预约”这个动作本身,而是库存、名额、状态、权限这四件事的一致性和安全性。任何一个环节没想清楚,上线后都会出事故。尤其是并发场景下的超卖问题,很多新手把精力花在花哨的页面上,等到部署到生产环境才发现库存对不上账,那就非常被动了。

如果你正打算做类似的预约系统(医院挂号、场馆预约、政务办事),这套架构完全可以作为底子来改。把疫苗批次换成医生排班或场地时段,把接种记录换成业务流水,核心的并发控制、状态流转、权限隔离思路都是通用的。

最后再分享一个小技巧:每个表的create_time和update_time字段一定要加,并且建表时就用DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP自动维护。这个小习惯在排查数据问题时价值极大,没有时间字段,出问题你连什么时候写入的都不知道,排查效率会大打折扣。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进