
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已关闭。这个状态机看着简单实际上撑起了整个订单流转逻辑。设计这几张表时的核心思想我觉得可以总结成一句话业务对象要清晰金额精度要严格状态流转要明确。后面所有代码都是围绕这些表展开的表结构如果出问题改起来比改代码痛苦得多。核心表关键字段设计要点userphone、password、user_type手机号唯一索引密码BCrypt加密scenicname、price、ticket_stock、longitude、latitude金额用DECIMAL库存用INTroutename、days、price、scenic_ids线路关联景点用逗号分隔ID并冗余名称ordersorder_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放开发环境配置连本地数据库和本地Redisapplication-prod.yml放生产环境配置通过profile激活。运行时通过启动参数指定环境比如 java -jar app.jar --spring.profiles.activeprod容器里则通过环境变量 SPRING_PROFILES_ACTIVEprod 指定。这里我特别强调一点数据库密码绝对不要明文写在application-prod.yml里尤其是项目代码要提交到Git仓库的场景。一旦仓库泄露线上数据库等于裸奔。推荐的做法是用环境变量注入比如spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/wxs_tourism?useSSLfalseserverTimezoneAsia/Shanghai username: ${DB_USERNAME} password: ${DB_PASSWORD}部署时在服务器或容器里注入DB_PASSWORD环境变量即可。如果要更严谨可以引入jasypt-spring-boot对密码做加密处理配置里存密文启动时用密钥解密。对毕设或中小型项目来说环境变量方案已经够用重点是别图省事硬编码。另外提醒一句MySQL连接串的时区参数。不加serverTimezoneAsia/ShanghaiJava 8以上版本连接MySQL 8时可能会报时区相关的错误或者出现时间差8小时的问题。这个问题我在5.1节还会细讲因为它困扰了我整整一个晚上。4.2 前后端联调跨域、Nginx反向代理与接口规范前后端分离带来的第一道坎就是跨域。前端Vue开发服务器跑在8081端口后端SpringBoot跑在8080端口浏览器会拦截跨域请求。开发环境下我推荐优先用前端代理去解决而不是在后端开CORS。Vue CLI的vue.config.js里配一个proxymodule.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序列化时区不匹配连接串加serverTimezoneAsia/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: GMT8 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块都打日志导致日志杂乱。第四不要为了简历好看去引入微服务、消息队列这些重技术。景区系统的并发量用单体加缓存完全扛得住过度设计只会让排查问题变得复杂。架构的合理性要看它是否匹配当前业务的规模。这个系统后续如果要继续扩展可以加景区实时客流大屏、智能推荐线路、会员积分体系也可以把民宿和餐饮商家接入做成平台化的旅游生态。但无论怎么扩展核心的订单、库存、支付链路设计思路已经在这个项目中沉淀下来了后续扩展只是在这个骨架上长肉而已。