1. 项目整体设计思路:从售票窗口变成线上服务台
1.1 需求拆解:万仙山景区管理系统到底要管什么
我接手这个项目的第一个动作,不是写代码,而是把景区业务理顺。万仙山这类自然山水景区有个很明显的特点:景点分散、旺季客流集中、住宿餐饮和门票强联动。旺季的时候,景区门口排队买票能排上百米,工作人员一边数现金一边对讲机喊话,民宿老板在路边举着牌子拉客,而办公室里的管理者想知道今天入园多少人,还得等下班后手工统计。
这套基于SpringBoot的万仙山旅游管理系统,核心就是把这些线下乱象搬到线上,做一套“线上服务台”。拆开来看,业务其实分两条线:C端游客要能查景点、看攻略、选线路、订门票、订民宿、在线支付;B端运营要能管景点信息、配线路、设库存、核销订单、看数据报表。
围绕这两条线,我把系统角色拆成四类:游客、票务员、运营管理员、财务。游客关心的是“怎么买票方便、怎么玩得明白”;票务员关心的是“核销快不快、退票麻不麻烦”;运营管理员关心的是“景点和线路怎么配置、库存怎么控制”;财务关心的则是“每一笔钱从哪来、退了多少”。需求一拆,后面的表结构和接口设计就有方向了,不会东一榔头西一棒子。
还有个容易被忽略的点:这类系统的并发量并不高,真正的高峰无非节假日那几天,但业务链路长,涉及门票、住宿、线路、支付、退款、核销。所以设计时不能只盯着“能用”,要盯住“订单和库存别出错”这条底线,尤其是票卖超了这种事故,一旦发生,整个项目的信任就崩了。
1.2 技术选型:SpringBoot不是唯一解,但一定是最稳解
技术选型这个环节,我经历过多轮比较。早期SSH那套配置繁琐得让人头疼,struts和spring的XML配置能写几百行;SSM好一些,但还是要手动配置一堆Bean。到了SpringBoot时代,自动配置加起步依赖,几十行配置就能把一个Web项目跑起来。
我实际用的是SpringBoot 2.7.x版本,配合MyBatis-Plus做持久层。选择SpringBoot的核心原因,除了开发效率,更看重它的生态成熟度和排查问题的便利性。现在网上一搜SpringBoot相关热词,从自动装配原理、多环境配置到Docker部署,资料非常全,遇到问题基本都能找到答案。整合Redis、JWT、微信支付这些常用组件,也都是起步依赖加几行配置的事,没有太多黑魔法。
有人问我为什么不直接上微服务,我反问他:一个景区业务系统,用户量级和业务复杂度远没到需要拆分的程度。微服务带来的服务发现、配置中心、分布式链路追踪,在这个项目里只会增加维护成本。单体架构加缓存,性能足够,排查问题还简单,出了故障看一份日志就行。架构是为业务服务的,不是用来炫技的。
技术栈最终确定为:SpringBoot 2.7 + MyBatis-Plus + MySQL 8 + Redis + JWT + Vue 3 + Element Plus。Redis在这套系统里承担两个关键任务:一是做景点详情、线路推荐等热点数据的缓存,二是通过Lua脚本解决门票库存的原子扣减问题。至于JWT,是为了让网页端、H5甚至小程序都能共用一套认证逻辑,无状态、跨端友好。
2. 数据库建模:四张核心表与一套库存规则
2.1 核心表结构设计:从用户到订单
数据库设计是这类系统的地基。我的习惯是先把核心业务对象列出来,再逐个展开字段。这套系统里,最核心的是四张表:用户表、景点表、线路表和订单表。
用户表相对简单,手机号做唯一索引,密码用BCrypt加密存储。这里有个细节要提醒:千万不要用MD5直接存密码,现在GPU算MD5的速度快到离谱,脱库基本上就等于明文泄露。我项目里用户表还带了用户类型字段,用来区分普通游客和景区内部员工,权限控制就靠它。
景点表比较有意思,除了基础的名称、简介、开放时间、票价之外,我还加了经纬度和热度字段。经纬度是为了后续做地图导览,热度字段则是为了首页推荐排序。值得说明的是,票价字段一定要用DECIMAL而不是FLOAT或DOUBLE,浮点数在MySQL里做精确计算会出幺蛾子,比如0.1加0.2变成0.30000000000000004,这在财务对账时是致命的。库存字段也很关键,我用的是普通整数字段,后续通过Redis配合解决并发扣减。
订单表是整个系统的核心,也是字段最多的表。订单号不搞自增ID,直接用“业务日期+随机数”的方式生成。为什么要这样?因为自增ID会暴露平台一天的订单量,而且多端创建订单时容易产生冲突。我在订单表里放了一个status字段,用整数枚举表示订单状态:0待支付、1已支付、2已核销、3已退款、4已关闭。这个状态机看着简单,实际上撑起了整个订单流转逻辑。
设计这几张表时的核心思想,我觉得可以总结成一句话:业务对象要清晰,金额精度要严格,状态流转要明确。后面所有代码都是围绕这些表展开的,表结构如果出问题,改起来比改代码痛苦得多。
| 核心表 | 关键字段 | 设计要点 |
|---|---|---|
| user | phone、password、user_type | 手机号唯一索引,密码BCrypt加密 |
| scenic | name、price、ticket_stock、longitude、latitude | 金额用DECIMAL,库存用INT |
| route | name、days、price、scenic_ids | 线路关联景点,用逗号分隔ID并冗余名称 |
| orders | order_no、status、total_amount、pay_no | 订单号业务化,状态机流转 |
2.2 门票库存与超卖问题:Redis Lua脚本的解法和补偿
库存超卖是票务系统最经典的坑,没有之一。我第一版实现写得很天真,直接在Service层先查库存,再判断是否大于0,然后扣减。并发测试一压就露馅了:两个请求同时查到库存还剩1,都判断“可以卖”,结果订单创建了2笔,数据库里库存变成了-1。
为什么会这样?因为“查询”和“扣减”是两个独立操作,中间的间隙就是并发窗口。靠 synchronized 加锁能解决单机问题,但系统部署多实例时就失效了,总不能让两个Tomcat进程共享一把JVM锁。用数据库乐观锁也可以,比如 UPDATE scenic SET stock = stock - 1 WHERE id = ? AND stock > 0,但每次扣减都要更新数据库,高峰期压力大。
我最终选择了Redis加Lua脚本的预扣方案。Lua脚本在Redis里是原子执行的,整个脚本执行期间不会插入其他命令,所以查库存和扣库存就成了一个不可分割的操作。核心脚本长这样:
直接调 jedisTemplate.execute 把KEYS和ARGV传进去,返回1表示预扣成功,返回0表示库存不足。这套方案的好处是:判断和扣减在同一个原子操作里完成,彻底消除了并发窗口,而且Redis是单线程模型,就算多个后端实例同时请求,也会在Redis端被串行处理。
但预扣只解决了“扣减”那一步,后面还有个大问题:库存扣了,订单却创建失败怎么办?比如用户下单后支付超时关闭订单,那么预扣的库存要回补;如果订单创建过程中发生异常,库存也要回补。我引入了一个定时补偿任务,每5分钟扫描一次超过30分钟未支付的订单,自动关闭并回补Redis库存。同时在业务代码里用try-catch包裹,一旦订单创建失败,立即调用Lua脚本回补库存。这两个保险加上,库存才真正稳了。
实际开发中还发现一个细节:Redis里的库存和数据库里的库存需要有一个对账机制。我每天凌晨跑一个定时任务,把数据库剩余库存与Redis库存做比对,不一致时以数据库为准并刷新Redis。虽然正常情况下不会不一致,但人工后台改了库存、或者Redis宕机重启,都需要这种兜底逻辑。数据这东西,宁可多一道校验,也不能裸奔。
3. 后端核心模块实现与实操记录
3.1 JWT无状态登录:为什么没用Session
认证方案一开始我纠结过,到底用Session还是JWT。用Session的好处是服务端可控性强,想踢人就踢人,但坏处是前后端分离场景下不好使。前端是Vue项目,部署在Nginx上,后端是SpringBoot,两者域名不同;如果用户再通过H5页面访问,跨域携带Cookie就更麻烦了。Session还要考虑分布式Session同步,要么引入Spring Session加Redis,要么自己实现,复杂度上来了。
JWT天然适合这种场景。用户登录成功后,后端生成一个Token返回给前端,前端存在localStorage里,每次请求在Header里带上Authorization即可。后端通过拦截器解析Token,校验签名和有效期,把用户信息塞进ThreadLocal供业务代码使用。这个方案无状态,后端不用存Session,随意横向扩展,换机器也不影响认证。
JWT的坑也很明显,最大的坑是无法主动失效。用户修改密码后,旧Token在有效期内依然能用;管理员封禁某个用户后,该用户已经拿到的Token还能继续访问。我的处理方式是引入Token版本号:用户表里加一个token_version字段,JWT里携带这个版本号,拦截器校验时对比版本号,不一致就拒绝。用户修改密码或被封禁时,顺手把版本号加一,旧的Token就自然失效了。
还有一个细节是Token过期时间的设置。我把它设成24小时,对于景区系统来说够用了。拦截器里我留了个状态码设计:Token过期返回401,前端收到401后跳转登录页;Token非法返回4001,前端清理本地Token。这样前端不用每次请求都解析错误信息,后端一个状态码就能表达清楚。
3.2 订单支付流程:状态机、幂等与超时补偿
订单模块是整个系统里状态流转最多的地方。我把订单状态机明确画出来后才发现,很多边界情况都藏在状态转换里。核心状态是:待支付→已支付→已核销,以及待支付→已关闭,已支付→已退款。
用户下单的流程是这样的:先选择线路或景点、选择出行日期和数量,后端校验参数合法性,然后对线路对应的所有景点执行Redis预扣库存操作。预扣成功后生成订单数据,状态为待支付,接着调用微信或支付宝的统一下单接口,拿到支付参数返回给前端。前端拉起支付,用户完成付款。
支付回调是整个流程最需要谨慎的地方,因为支付回调可能重复送达,也可能被人伪造。我处理回调接口时做了三件事:第一是验签,使用官方SDK的验签方法,确保回调确实来自支付平台;第二是幂等检查,根据回调里的商户订单号查订单,如果订单已经是已支付状态,直接返回成功应答,不再重复处理;第三是幂等处理,支付成功后只需要把订单状态改为已支付,不需要再扣库存,因为库存在下单时已经预扣了。
超时未支付的订单怎么处理?我写了一个定时任务,每5分钟扫描一次订单表,查出所有超过30分钟仍处于待支付状态的订单,把它们改成已关闭状态,同时回补Redis库存。这里要注意一个细节:订单关闭和支付回调之间存在竞态。如果用户在30分钟临界点支付了,但定时任务先一步把订单关闭了,就会导致用户钱付了、订单却被标记为关闭。
我的解决方式是:定时任务在关闭订单之前,先查询支付平台的订单状态,如果确实未支付才关闭;如果已经支付,就把订单改成已支付,同时发一封通知邮件给运营人员,让运营介入处理。虽然这种情况极少发生,但线上系统要的就是这种边界兜底。
3.3 评论与评分缓存:读多写少的正确姿势
评论和评分功能,业务上不复杂,但却是访问量最大的接口之一。景区的评分和评论会展示在首页、景点详情页、线路详情页,游客浏览时几乎每个页面都会触发查询。如果每次都去数据库里查评论列表、算平均分,数据库压力很快就上来了。
我的做法很简单:写库之后更新缓存。用户提交评论后,先写入评论表,然后重新计算该景点的平均评分,写入Redis。缓存key设计成 scenic:comment:景区ID 和 scenic:score:景区ID。读取时先查缓存,命中了直接返回,没命中再查数据库,并回填缓存。
但有三个缓存经典问题必须处理掉。第一是缓存穿透:如果用户查了一个不存在的景点ID,缓存和数据库都没有数据,请求就会直接打到数据库。我的处理是对空结果也做缓存,TTL设置短一点比如30秒,避免恶意请求反复穿透。第二是缓存击穿:某个热点景点的缓存刚过期,大量请求同时来查数据库。我用互斥锁解决,只让一个请求去查库,其他请求短暂等待后复用第一个请求回填的缓存。第三是缓存雪崩:这个项目缓存数量不多,我通过给不同缓存设置不同的过期时间,比如基础分加随机值,打散过期时刻。
这里我要专门说一个问题,可能是很多新手会犯的:更新缓存的时机。有些人图省事,写了数据库之后直接删缓存,等下次请求再重建缓存。这个思路其实也行,但如果删除缓存和重建缓存之间刚好有请求进来,就会读到旧数据。我更推荐的做法是:写库成功后在同一个事务内更新缓存,虽然多了一次写操作,但能保证数据一致性。对于景区系统这种读多写少的场景,这一点点开销完全值得。
4. 从配置到部署:多环境隔离与Docker落地
4.1 多环境配置:dev/prod分离与敏感信息处理
配置管理看着简单,实际踩过坑才知道重要性。一个SpringBoot项目,本地开发连本地MySQL,测试环境连测试库,线上连线上库,如果全部写死在application.yml里,每次发版都要手工改配置,改错一个数据库地址就等着半夜被叫起来修吧。
我的做法是拆成多份配置文件:application.yml放公共配置,比如应用名、端口号、Jackson序列化规则;application-dev.yml放开发环境配置,连本地数据库和本地Redis;application-prod.yml放生产环境配置,通过profile激活。运行时通过启动参数指定环境,比如 java -jar app.jar --spring.profiles.active=prod,容器里则通过环境变量 SPRING_PROFILES_ACTIVE=prod 指定。
这里我特别强调一点:数据库密码绝对不要明文写在application-prod.yml里,尤其是项目代码要提交到Git仓库的场景。一旦仓库泄露,线上数据库等于裸奔。推荐的做法是用环境变量注入,比如:
spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/wxs_tourism?useSSL=false&serverTimezone=Asia/Shanghai username: ${DB_USERNAME} password: ${DB_PASSWORD}部署时在服务器或容器里注入DB_PASSWORD环境变量即可。如果要更严谨,可以引入jasypt-spring-boot对密码做加密处理,配置里存密文,启动时用密钥解密。对毕设或中小型项目来说,环境变量方案已经够用,重点是别图省事硬编码。
另外提醒一句MySQL连接串的时区参数。不加serverTimezone=Asia/Shanghai,Java 8以上版本连接MySQL 8时可能会报时区相关的错误,或者出现时间差8小时的问题。这个问题我在5.1节还会细讲,因为它困扰了我整整一个晚上。
4.2 前后端联调:跨域、Nginx反向代理与接口规范
前后端分离带来的第一道坎就是跨域。前端Vue开发服务器跑在8081端口,后端SpringBoot跑在8080端口,浏览器会拦截跨域请求。开发环境下,我推荐优先用前端代理去解决,而不是在后端开CORS。
Vue CLI的vue.config.js里配一个proxy:
module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }这样前端请求 /api/user/login,实际上被代理转发到 http://localhost:8080/api/user/login,对浏览器来说请求的是同源地址,跨域问题直接消失。
生产环境则用Nginx做反向代理,配置也很简单:
location /api/ { proxy_pass http://backend-server:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }如果确实要在后端开CORS,注意allowedOrigins不要用 * 通配符,尤其当接口需要携带Cookie或Authorization头时,通配符和allowCredentials(true)不能共存。实际项目里我倾向于用Nginx统一处理跨域,后端代码保持干净。
接口规范方面,我的习惯是统一返回结构:code、message、data三段式。code为200表示成功,其他为业务错误码。前端封装了axios拦截器,统一处理错误码和401跳转。还有一个细节是分页参数统一用pageNum和pageSize,排序字段统一用orderBy和orderDirection,这样后端写分页拦截器时不用给每个接口适配不同的参数名。规范这种东西,前期不花时间定,后期联调能多花几倍时间。
4.3 Docker部署:一个Dockerfile和一个编排文件搞定
以前部署SpringBoot项目是手工上传jar包、nohup启动、再手动启停,操作繁琐还容易出错。后来我把整套环境改成Docker Compose编排,一条命令解决依赖环境和应用本身的部署。
应用的Dockerfile非常简洁:
FROM eclipse-temurin:17-jre WORKDIR /app COPY target/wxs-tourism-1.0.0.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]这里用了一个小技巧:容器里运行程序,尽量选jre基础镜像而不是jdk,镜像体积能小一半。多阶段构建也可以,把编译阶段和运行阶段分开,最终产物只保留运行环境。
接着用docker-compose.yml把MySQL、Redis和应用编排到一起:
services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} volumes: - mysql-data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 app: build: . ports: - "8080:8080" environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql DB_PORT: 3306 DB_USERNAME: root DB_PASSWORD: ${MYSQL_ROOT_PASSWORD} depends_on: mysql: condition: service_healthy redis: condition: service_healthy这里值得重点说的是depends_on和healthcheck配合的用法。SpringBoot应用启动时如果MySQL还没就绪,会报连接异常然后启动失败。光靠depends_on是不够的,因为depends_on默认只保证容器启动顺序,不保证服务已经就绪。加上healthcheck后,配套的condition: service_healthy能确保MySQL和Redis真的能接受连接了,才启动应用。
部署当天还有个小插曲:本地打包启动没问题,放到Docker容器里就报连接数据库超时。排查了半天,最后发现是因为应用配置里数据库地址写成了localhost。在容器里,localhost指向的是容器自身,而不是宿主机上的MySQL容器。改成服务名mysql后,问题立刻消失。这个坑很典型,写出来给大家提个醒。
5. 常见问题与排查实录
5.1 线上问题速查表:八个典型故障与解法
项目开发和上线过程中遇到的问题,我整理了一个速查表。这些问题都非常典型,基本覆盖了SpringBoot项目从开发到部署的各个阶段。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 接口返回时间比数据库时间少8小时 | JDBC连接串没设置时区或Jackson序列化时区不匹配 | 连接串加serverTimezone=Asia/Shanghai,配置Jackson时区 |
| Docker里应用连不上数据库 | 配置用了localhost,指向容器自身 | 改用docker-compose中的服务名 |
| 并发下单时库存变负数 | 查询和扣减不是原子操作 | 改用Redis Lua脚本预扣库存 |
| 支付回调重复处理 | 回调可能重试,业务未做幂等 | 回调先查订单状态,已支付直接返回成功 |
| 上传文件触发XSS报错 | 文件名或内容包含特殊字符被过滤器拦截 | 配置全局XSS过滤器白名单,对富文本字段单独放行 |
| 前端报跨域错误 | 生产环境未配置Nginx代理或CORS | 统一用Nginx代理,allowedOrigins不用通配符 |
| 缓存和数据库数据不一致 | 写库后删缓存,读请求重建缓存时读到旧数据 | 写库后直接更新缓存,定时任务兜底对账 |
| 应用启动后端口被占用 | 有其他进程占用8080 | 使用netstat或lsof定位进程,或者配置随机端口 |
其中时间少8小时这个问题,我想展开说一下。它有两个产生位置,一个是JDBC驱动在连接数据库时对DATETIME类型做转换,一个是Jackson在把Java的Date类型序列化成JSON时使用的时区。我一开始只改了数据库连接串,解决了读写数据库的问题,但接口返回JSON仍然差8小时。最后在application.yml里加了一段配置,两个位置同时处理才彻底根治:
spring: jackson: time-zone: GMT+8 date-format: yyyy-MM-dd HH:mm:ss这个配置同样适用于前端时间展示不一致的排查思路,遇到时间类问题,先分清是数据库层还是JSON序列化层的问题,逐个击破。
5.2 读懂SpringBoot自动装配原理,排查问题快一倍
遇到“我明明配置了为什么没生效”这类问题,如果不懂自动装配原理,排查起来会非常痛苦。SpringBoot的自动装配其实就三板斧。
首先,启动类上的@SpringBootApplication是个复合注解,里面包含了@SpringBootConfiguration、@EnableAutoConfiguration和@ComponentScan。其中@EnableAutoConfiguration是自动装配的入口,它通过@Import引入了一个自动配置导入选择器。
其次,选择器会扫描classpath下的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件。这个文件里列了所有候选的自动配置类,比如DataSourceAutoConfiguration、RedisAutoConfiguration、JacksonAutoConfiguration。SpringBoot会根据当前classpath下有没有对应的类来决定要不要装配这个配置。
最后,候选配置类内部大量使用了条件注解,比如@ConditionalOnClass、@ConditionalOnProperty、@ConditionalOnMissingBean。这些注解是自动配置的“开关”,满足条件才装配,不满足就跳过。比如项目中引入了redis的jar包和配置,RedisAutoConfiguration就会生效;如果没有配置Redis连接信息,它又会通过@ConditionalOnMissingBean让用户自定义的配置生效。
明白这个原理后,很多问题一眼就能看出症结。比如配置了Redis但连接不上,先确认是不是引入了redis依赖;配置了数据源但没生效,先看看是不是classpath下没有对应的数据库驱动;自定义了数据源却发现被覆盖,检查是不是没有加@Primary或者@ConditionalOnMissingBean机制把自定义Bean排除了。
当年SpringBoot刚火的时候,自动装配被吹得神乎其神,深入了解之后发现底层就是“约定优于配置”加“条件装配”两个思想的组合。面试里常问的SpringBoot自动装配原理,实际上就是这套流程。对开发者而言,理解它最大的价值不在面试,而在真正遇到配置问题时能快速定位是哪个环节断了。
最后再分享几点个人体会
整个项目从需求梳理到上线交付,回头来看,真正让我成长的不是写了多少行代码,而是把几个关键问题想透了。
第一,订单状态机一定要先画清楚再动手写代码。我见过太多同学一上来就写“状态更新”的逻辑,结果退款、关闭、支付成功几个状态搅在一起,改来改去全是bug。状态机画清楚后,代码反而写得很快,因为每个状态转移都是明确的一条分支。
第二,库存这种核心资源,宁可多写一个对账任务也不要裸奔。Redis预扣解决并发问题,定时补偿解决异常问题,日终对账解决数据不一致问题,三道防线缺一不可。
第三,接口统一返回结构和统一异常处理,前端联调体验会好很多。这个项目里我封装了全局异常处理器,把所有业务异常、参数校验异常、未知异常都统一转换为标准响应结构,日志在异常处理器里统一打一次,避免每个catch块都打日志导致日志杂乱。
第四,不要为了简历好看去引入微服务、消息队列这些重技术。景区系统的并发量用单体加缓存完全扛得住,过度设计只会让排查问题变得复杂。架构的合理性,要看它是否匹配当前业务的规模。
这个系统后续如果要继续扩展,可以加景区实时客流大屏、智能推荐线路、会员积分体系,也可以把民宿和餐饮商家接入做成平台化的旅游生态。但无论怎么扩展,核心的订单、库存、支付链路设计思路,已经在这个项目中沉淀下来了,后续扩展只是在这个骨架上长肉而已。