news 2026/10/2 4:01:40

Spring Boot+Vue古城景区管理系统:预约限流与库存并发扣减实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot+Vue古城景区管理系统:预约限流与库存并发扣减实践

去年夏天,我接手了一个小型古城景区的信息化改造需求。管理处的诉求非常直接:暑期客流高峰期,线下售票窗口排队能排到街口,检票口全靠人工数人头,游客在巷子里绕半天找不到洗手间,商户跟景区总部的账目核对一周都出不来结果。这些零散问题归结到一点,就是景区缺一个能把票务、导览、商户、数据统一收口的系统。于是有了这个基于Spring Boot + Vue的古城景区管理系统。项目规模不算大,但完整覆盖了前后端分离架构、RBAC权限控制、票务库存并发扣减、MinIO文件存储、m3u8视频播放、Docker部署上线这一整条技术链路。如果你是做毕设的学生、想入行旅游信息化开发的新人,或者本身就是中小景区IT部门的人,这套系统的设计思路和踩坑记录都值得直接参考。

1. 古城景区管理系统的需求拆解与角色边界

1.1 古城景区到底"难"在哪里

普通景区管理系统在网上能搜到大量现成案例,但古城类项目有几个特殊性,直接决定了系统设计方向。

古城的景点是分散的,一个古城往往包含十几个甚至几十个独立景点、文保单位、博物馆和体验街区,票务天然分为单景点票和多景点联票。古建筑本身的承载能力有限,像木结构建筑、狭窄巷道、文物展厅,都有明确的瞬时容量上限,所以必须做分时段预约限流,不能像普通公园一样敞开了卖票。古城里还活跃着大量商户——餐饮、民宿、文创、汉服租赁,这些商户跟景区总部之间既有入驻关系又有分成结算关系,财务数据需要线上化。最后是导览,游客拿着手机在复杂的街巷里走,需要清晰的定位、点位介绍和路线规划。

这些需求落到系统里,就自然拆解出四大模块:基础资源管理(景区、景点、设施点位)、票务中心(票型、库存、订单、退改)、预约限流(分时段额度控制)、商户运营(商品、订单、结算数据),再加上一个面向管理层的统计看板。整个项目的主干就是这样来的,不是拍脑袋功能堆叠,而是业务倒逼出来的。

1.2 四类角色与权限边界

系统的使用角色我一开始就定为四类,没有再做细分,因为再多权限模型就复杂了,对中小景区来说反而是负担。

  • 系统管理员:拥有全部后台模块的权限,包括账号分配、角色授权、数据字典维护。
  • 景区运营人员:负责景点和票型的内容维护、订单审核、预约额度调整、视频和图片素材上传。
  • 商户端用户:只能看到自己店铺的商品、订单和结算单,数据范围受 merchant_id 隔离。
  • 游客:通过Web端或小程序完成注册登录、购票预约、地图导览、直播回看。

权限模型用经典的RBAC,用户-角色-菜单三级关联。实际开发中我建议在菜单表上直接挂一个路由地址字段,前端登录后拿到的路由表就是从菜单表生成出来的,这一步给后面Vue动态路由埋好了伏笔。

1.3 为什么要把预约限流单独立项

这是整个项目里最容易被低估的模块。古建景点的承载量是安全红线,不能超卖,也不能靠现场人工眼估。我做的是一个按日的分时段库存表,每个景点每天拆成若干个时段(比如8:00-10:00、10:00-12:00这种),每个时段一个库存额度。游客下单时先锁时段库存,支付成功后才正式占用。到了旺季,运营人员只需要在后台调整每个时段的额度数字,前端大屏和游客端会同步看到"当前时段余票紧张"的提示。

这个设计不复杂,但非常管用,它把一个安全合规问题转化成了一个数据库并发控制问题。后面我会专门讲库存扣减的实现细节,因为这个环节如果做不好,节假日流量一来就会出现超卖事故。

2. 技术选型与工程结构搭建:为什么是这个组合

2.1 技术栈清单

这套系统后端选的Spring Boot 2.7.18,JDK用的8,前端是Vue 3 + Vite + Pinia + Element Plus,数据库MySQL 8.0,缓存Redis,文件存储MinIO,权限认证JWT。整体清单如下表。

层次选型版本建议
后端框架Spring Boot2.7.x
ORMMyBatis-Plus3.5.x
数据库MySQL8.0
缓存Redis6.x/7.x
对象存储MinIORELEASE.2023.x
认证JWT(jjwt)0.11.x
前端框架Vue 3 + ViteVite 4.x
UI组件Element Plus2.x
状态管理Pinia2.x
地图腾讯地图JavaScript API官方最新
视频播放video.js + hls.js最新稳定版

为什么不用Spring Boot 3.x?不是它不好,是这个项目的第三方依赖生态还没完全跟上。搜索词里经常有人问"springboot版本太高怎么办",我自己的经验是:中小项目和企业项目里,版本稳定远比版本新重要。Spring Boot 3.x强制JDK 17起步,一些老牌的Spring Cloud组件、代码生成器、部分数据库方言还需要适配,如果团队里有人不熟,排查成本立马上来了。2.7.x生命周期很长,足够支撑这个体量的项目跑好几年。

2.2 后端分层与统一返回结构

后端我采用标准的Controller-Service-Mapper三层,没有引入DDD那套复杂设计。项目结构大致是这样的:

com.city.backend ├── controller # 接口层 ├── service # 业务逻辑层 ├── mapper # MyBatis-Plus Mapper ├── entity # 数据库实体 ├── dto # 入参出参对象 ├── config # 配置类(Redis、MinIO、拦截器、跨域) ├── common # 统一返回、异常、常量、工具类 └── job # 定时任务

所有接口统一返回Result对象,结构是 code、message、data 三段。全局异常处理器捕获业务异常、参数校验异常和未知异常,前端Axios拦截器只需要判断 code 就能知道请求成不成功,不需要每个接口写重复的 try-catch。业务异常我建议用枚举维护,错误码不要散落在各个Service类里,以后做国际化或者对接第三方告警会省很多事。

2.3 前端工程结构

前端目录结构是按业务模块组织的,不是按页面堆的:

src ├── api # 每个模块一个api文件 ├── router # 静态路由 + 动态路由注册 ├── store # Pinia ├── views # 页面组件 ├── components # 通用组件 ├── utils # 请求封装、工具函数 └── layout # 后台布局

api目录和views目录按模块一一对应,比如 ticket.js 对 should 票务页面。这样做的直接好处是,后端的Controller路径、前端的api文件、views目录三个地方能快速对应上,排查问题的时候不用满项目翻文件。很多初学者习惯把几个页面写在同一个目录里,项目小没事,功能一多就会乱。

2.4 引入MinIO而不是本地磁盘

景区系统的图片、视频资源量很大,景点介绍图、文化宣传片、直播回放、商户商品图,动辄几个GB。如果用本地磁盘存储,部署环境一换就得迁移数据,而且Nginx对动态生成的访问路径也不友好。MinIO是S3协议兼容的对象存储,单机部署非常轻,Docker跑一个容器就行。它的Bucket策略、预签名URL、防盗链配置都做得很完善,后面视频播放和文件访问都会依赖它。这个选型我强烈建议保留,不要图省事改成磁盘路径。

3. 数据库设计:先让这些表能"动"起来

3.1 核心业务表清单与关系梳理

古城景区系统的表大概二十多张,核心的可以分成五组。

基础资源组:景区表、景点表、设施点位表(洗手间、停车场、游客中心)、景点媒体表(图片、视频)。

票务库存组:票型表、库存表(按景点+日期+时段拆分)、订单表、订单明细表、退票记录表。

用户权限组:用户表、角色表、菜单表、用户角色关联表、角色菜单关联表。

商户组:商户表、商户用户表、商品表、商品订单表、结算表。

运营统计组:日统计表、时段客流表、销售汇总表。

表关系的核心是:一个景区下多个景点,一个景点多个票型,一个票型在某个日期时段上有独立库存;游客下单生成主订单,主订单下挂多个明细(联票就是一个订单多条明细),每条明细对应一个票型和一个时段。

这套设计有一个关键点需要注意:库存表和订单明细表是分离的。不要试图在订单明细表里直接减库存,那样报表统计和库存恢复会非常难受。库存单独一张表,订单支付成功再去占用库存,退款时再释放库存,逻辑清晰,出问题也好排查。

如果项目有国产化要求,这套表结构换成国产数据库也完全能用,MyBatis-Plus的方言适配做得比较成熟,改动量集中在数据源和SQL方言配置上,业务代码基本不动。

3.2 订单表的一个实战设计

订单表是全局最核心的表之一,我贴一下精简后的字段:

字段类型说明
order_novarchar(32)业务订单号,全局唯一
user_idbigint下单用户
merchant_idbigint所属商户,普通票为0
total_amountdecimal订单总金额
statustinyint0待支付 1已支付 2已使用 3已退款 4已关闭
pay_timedatetime支付时间
use_timedatetime首次核销时间
refund_timedatetime退款时间
create_timedatetime下单时间

订单号我要求必须业务生成,格式类似 "G + yyyyMMddHHmmss + 四位随机数",坚决不用数据库自增ID做订单号。原因很简单:第一,订单号会出现在用户的支付凭证和客服沟通里,自增ID会泄露当日订单量;第二,分库分表时自增ID会冲突,业务订单号天生具备全局唯一性;第三,运营人员在后台Excel里按订单号筛选时,业务编号比纯数字可读性高得多。

3.3 库存扣减怎么避免超卖

这个系统里最需要并发控制的就是库存。我用的是"Redis预扣 + 数据库行锁兜底"的组合方案,简单说就是两层保护。

Redis预扣:用户点击下单时,先到Redis里对 (ticketTypeId + date + timeSlot) 这个key执行 DECR,如果返回负数说明没库存,直接提示"该时段已约满";如果扣减成功,生成待支付订单,并把预扣的key和订单号放进Redis,设置15分钟过期。

数据库兜底:用户支付成功回调时,执行一条带条件更新的SQL:UPDATE stock SET sold = sold + 1 WHERE ticket_type_id = ? AND date = ? AND time_slot = ? AND sold < quota。受影响行数为0说明这个时段在支付瞬间已经被占满,此时走退款流程。这条SQL利用的是数据库行锁特性,多个并发事务同时更新同一行时会被串行化执行,sold < quota 这个条件确保了最后一人卖出去后其他人无法再成功。

这套方案的好处是:Redis承担了绝大部分查询和预扣压力,数据库只在支付环节做最终一致性校验,既不会超卖,也不会把数据库拖垮。

3.4 统计报表:别让前端实时聚合

后台管理需要各种统计图:日销售额、时段客流、热门景点排行。如果这些数据都靠前端实时请求订单表聚合,数据库压力会随查询时间范围急剧上升,尤其是国庆、春节这种大流量时期。

我的做法是每天凌晨2点用定时任务把前一天的数据聚合好,写入 statistics 日统计表。前端所有图表接口都只查这张统计表,查询条件只有日期范围,走索引非常快。实时性要求高的数据(比如"当前在园人数")才单独走Redis实时计算,其他做过往趋势分析的,全部走预聚合。这套逻辑在数据量几千条的阶段看不出差距,但数据量到十万级以后,实时聚合接口的响应时间会成倍劣化。

4. 后端从登录鉴权到票务扣减的实现细节

4.1 JWT鉴权:拦截器写成一道门槛,权限用注解收口

JWT本身不复杂,签发、解析、过期校验三件事,核心在于怎么用好它。我把鉴权设计成两层:拦截器负责判断"你是谁",权限注解负责判断"你能做什么"。

登录成功后后端生成Token,包含userId、角色编码、过期时间,返回给前端存到localStorage。前端每次请求在Authorization头带上Token。后端写一个JwtInterceptor,在 preHandle 里解析Token,把用户信息放进ThreadLocal,供Service层直接取当前用户。

第二层是权限收口,自定义一个 @RequirePermission 注解,挂在需要权限的Controller方法上,写一个切面在方法执行前校验当前用户角色是否拥有对应权限码。游客、运营、商户、管理员之间的接口隔离就靠这个注解完成。这里给个建议:权限码用字符串枚举管理,比如 ticket:add、ticket:audit、merchant:settlement。不要用数字1、2、3来代替权限,那种方式扩展权限时会很痛苦。

如果未来需要开放一些接口给小程序或者第三方渠道,建议再往上加一层签名认证。做法是约定一个appSecret,把参数按照key字典序拼接后加盐做MD5或HMAC,服务端再用同样算法校验一遍。热搜词里出现的"springboot签名认证"说的就是这件事,本质是防止参数被篡改和简单的身份伪造。

4.2 票务库存扣减的完整时序

我把库存扣减的链路完整跑一遍,这一步是踩坑最多的。

游客选择景点、日期、时段,点击"立即预约"。后端先做参数校验,然后走Redis预扣,预扣成功生成待支付订单,返回订单号给前端。游客在15分钟内完成支付,支付回调接口收到通知后,执行数据库条件更新,把订单状态改成已支付,同时更新库存表的sold字段。如果数据库更新失败,说明这个时段恰好在这15分钟内卖光了,系统自动将订单置为已退款,并通知游客。如果游客超过15分钟未支付,定时任务扫到待支付订单,先把Redis预扣的额度加回去,再把订单置为已关闭。

这里有个细节:为什么支付成功后才更新数据库库存,而不是在下单时就更新?因为如果下单就更新数据库库存,那大量用户下单不支付会导致库存被"僵尸订单"长期占用,游客明明看到有余票却买不到。把数据库更新放在支付环节,库存的有效占用才是真实的。这个取舍如果你在实际项目里想清楚了,后续很多报表问题都会迎刃而解。

4.3 文件上传:MinIO的Bucket设计与访问控制

文件部分我用MinIO做对象存储,管理后台的图片、视频都走统一上传接口。项目里规划了三个Bucket:

  • scenic-image:景点和商户图片,公开读,Nginx直接回源。
  • scenic-video:宣传视频和直播回放,加密读,需要带时间戳签名。
  • private-files:财务导出、结算单等敏感文件,完全私有。

MinIO的Bucket策略可以精确到路径前缀,比如 scenic-image 下的所有对象都允许 GetObject,而 private-files 默认全部拒绝,访问时用预签名URL按分钟授权。前端上传大视频时,我建议直接用MinIO的预签名PUT接口,让浏览器直传对象存储,不经过后端应用服务器转发。这样做的好处是,几GB的视频文件不会占用后端带宽,也不会出现请求超时。

视频这块,景区的宣传片和直播回放我统一转成m3u8切片格式再上传,前端播放器直接拉.m3u8索引文件。MinIO作为静态源站表现很稳,切片文件的HTTP缓存命中率也很高。m3u8在这个场景里是刚需:视频文件大,切片后可以按需加载、拖拽流畅,配合CDN做边缘缓存也自然。

4.4 中文搜索:HanLP分词的实际用处

古城景区的搜索需求大多是"我要找XX宫""XX寺在哪",这类中文地名、文化词条用数据库的 LIKE '%关键词%' 搜索体验非常差。比如游客搜"关帝庙",数据库里存的是"关帝庙景区",LIKE '关帝庙' 能命中,但搜"关帝庙在哪"或者"关公庙"这种近似词就完全查不到。

我在搜索模块引入了HanLP分词。游客输入搜索词后,先用HanLP做中文分词和词性标注,把主干名词提取出来,再用这些词去匹配景点名称、别称、标签字段。HanLP是纯Java实现,引入依赖就能用,不需要额外部署Python服务,和Spring Boot配合很自然。实际效果是,搜"古城里哪里能看皮影戏",能正确分词出"皮影戏"这个核心词,匹配到对应的体验馆景点,而不是让全文检索在数据库里做一次低效的全表扫描。

4.5 ActiveMQ异步消息:什么场景值得上MQ

这个项目引入了ActiveMQ做异步消息,但没有滥用。我只看重一个场景:预约成功后的多端通知。

游客支付成功后,系统需要发送站内信通知、商户需要收到新订单提醒、运营人员可能需要收到预约高峰预警邮件。如果这三个动作都在支付回调线程里同步做完,支付接口的响应时间会明显拉长,而且任何一个下游服务抖动都会拖累主流程。

用ActiveMQ之后,支付回调只做一件事:把"支付成功"消息丢进队列。消费者分别去处理站内信、商户通知、预警统计。ActiveMQ在Spring Boot里的配置很简单,spring-boot-starter-activemq 引入后,写一个消息监听器即可。如果你的系统还在起步阶段,没有异步通知需求,完全可以用Spring自带的 @Async 顶替,不一定非得上MQ。这个判断很重要——上MQ是为了解耦和削峰,不是为了列表里多一个技术名词。

顺带说一句,很多人忽略的Spring Boot启动Banner,其实是个很小的团队认同感细节。用banner生成器定制一个项目名和版本号的ASCII艺术字,每次启动都看到,团队成员会自然形成一种"这是我们的项目"的代入感,成本几乎为零,效果好过发内部通知。

5. 前端核心功能落地:动态路由与地图导览

5.1 动态路由与菜单权限的完整思路

后台管理系统的权限控制,光有后端拦截器不够,前端菜单也得按角色动态生成,否则用户能通过URL直接访问没有权限的页面。

登录流程是这样的:登录接口返回Token和用户基本信息,前端拿着Token请求 /user/permissions 接口,后端返回该用户可见的菜单列表和路由表结构。前端拿到路由表后,通过 Vue Router 的 router.addRoute 按层级动态注册,同时把菜单列表存进Pinia,由侧边栏组件渲染。

路由守卫里有一套关键逻辑:用户手动刷新页面时,Pinia里的菜单状态是空的,路由表也会丢失。此时必须在 beforeEach 里检测到"已登录但路由表未加载"这个状态,重新拉取权限并动态注册路由,然后放行到目标页。很多初学者在动态路由上反复踩坑,绝大多数都是刷新后路由丢失变成空白页,根因就是没有在全局守卫里做路由表的重放。

核心代码逻辑是:

// 全局前置守卫 router.beforeEach(async (to, from, next) => { const token = localStorage.getItem('token') if (token) { if (to.path === '/login') { next({ path: '/' }) } else { const userStore = useUserStore() if (!userStore.hasRoute) { const menus = await userStore.fetchPermissions() const routes = buildRoutes(menus) // 根据菜单生成动态路由表 routes.forEach(route => router.addRoute(route)) userStore.setHasRoute(true) next({ ...to, replace: true }) // 重进一次目标路由 } else { next() } } } else { if (to.path === '/login') { next() } else { next('/login') } } })

如果不在 addRoute 之后执行 next({ ...to, replace: true }),而是直接 next(),有些场景下路由表是异步注册的,当前导航匹配不到目标路由,页面会空白。这个细节是我自己调了很久才发现的。

5.2 地图导览页:腾讯地图与景点点位

游客端的核心页面之一就是地图导览。我用的腾讯地图JavaScript API,按需加载Script的方式引入,没有在入口文件一次性加载所有地图SDK。

页面逻辑:地图初始化完成后,从后端拉取所有景点设施点位数据,每个点位是一个Marker,Marker上带景点ID。点击Marker弹出信息窗,展示景点名称、简介、开放时间、余票状态,并提供"查看详情"和"去这里"两个动作。"去这里"调用腾讯地图的路线规划接口,直接从游客当前位置规划步行路线。古城的巷子窄、路径多,步行导航比驾驶导航实用得多。

踩过的坑有两个。第一个是地图SDK加载时机问题,腾讯地图的脚本加载是异步的,如果初始化代码在SDK加载完成前执行,会报 qq is not defined。我封装了一个Promise化的加载函数,确保SDK加载完再调用地图初始化。第二个是Marker数量过多时性能下降,古城点位动辄几十个,如果全部用独立Marker渲染,低端手机上拖动地图会掉帧。我后来改为点聚合方式,缩放级别高时才展开为单个Marker,性能明显改善。

5.3 Axios封装与Token刷新机制

前端请求层我用一个统一的Axios实例,主要解决三件事:自动带Token、统一错误提示、401无感刷新。

请求拦截器从localStorage拿到Token,放到Authorization头。响应拦截器统一处理业务错误码,遇到401说明Token过期,这时候需要自动调用刷新Token接口,拿到新Token后重放当前失败请求,而不是直接把用户踢回登录页。无感刷新对体验提升很大,尤其是游客在填写订单信息时Token过期,如果直接跳登录页,用户心态会崩。

Vite开发环境下,前端本地调试请求后端接口,需要在 vite.config.js 里配置代理,把 /api 前缀的请求转发到本机后端端口,同时通过 changeOrigin 解决跨域问题。生产环境则由Nginx代理,这个后面展开。

5.4 m3u8视频播放:video.js + hls.js组合

古城景区的宣传视频和文化介绍,在游客端有两个展示位置:首页轮播和景点详情页。视频源格式统一是m3u8切片,浏览器原生video标签不支持直接播放m3u8,特别是Chrome桌面端和部分移动端浏览器。

我的做法是封装一个VideoPlayer组件,核心依赖是video.js配合hls.js:

  • hls.js负责把m3u8流解析成MP4分片,喂给video元素。
  • video.js提供统一的播放器UI、进度条、音量、全屏控制。
  • 播放器支持自动播放策略处理,有声音的视频在Chrome里不允许自动播放,需要静音后尝试,用户点击后再恢复声音。

组件里还需要处理一个经典问题:m3u8所在Bucket如果设置了防盗链,需要带签名URL。前端播放器拿到的必须是带时效签名的完整地址,过期后需要重新向后端申请。我的实现是监听播放器的 error 事件,遇到404或签名过期就重新拉取播放地址继续播,这样游客在看长视频时不会因为签名过期中断。

5.5 前端打包放进Spring Boot的两种方式

项目里两种部署方式我都配置过,给出对比。

方式一:前后端分离部署。前端 build 产物丢给Nginx,后端打成jar用Docker跑。好处是两个服务可以独立扩缩容,前端静态资源可以放CDN,后端接口可以做独立监控。推荐生产环境用这种。

方式二:前端打成一个包塞进后端。把前端 build 产物复制到后端 src/main/resources/static 目录下,再一起打成jar。这样整个系统只有一个Java进程,部署极其简单,适合演示环境、毕设答辩或者内部小范围使用。缺点是无法享受Nginx的静态资源处理能力,所有静态文件请求都经过Spring MVC,大并发下效率一般。

如果你的项目日后要接小程序,建议从一开始就走方式一,让Nginx承担静态资源和反向代理,小程序接口请求直接指向同一个域名下的 /api 路径,不会遇到跨域问题。

6. 部署上线与踩坑记录

6.1 Docker Compose一键编排

服务器部署我全部走Docker Compose,一台2核4G的云主机就能跑完整套系统。编排文件里有五个服务:MySQL、Redis、MinIO、后端jar、前端Nginx。

后端Dockerfile的核心是:

FROM openjdk:8u212-jre-alpine MAINTAINER city-backend RUN apk add --no-cache tzdata ENV TZ=Asia/Shanghai COPY target/city-backend.jar app.jar ENTRYPOINT ["java", "-jar", "-Xms256m", "-Xmx512m", "/app.jar"]

注意时区必须设置成 Asia/Shanghai,否则日期统计会出现8小时偏差,排定时任务的报表数据会错位。

前端Nginx配置里要做的关键事情是:Vue Router的history模式需要配合 try_files;/api 路径反向代理到后端服务;上传大小限制放到client_max_body_size;开启gzip压缩静态资源。

server { listen 80; server_name your-domain.com; gzip on; gzip_types text/plain text/css application/javascript application/json image/svg+xml; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; client_max_body_size 100m; } }

try_files 这行是history路由模式的生命线,少了它,前端路由刷新页面就会404。

6.2 我在这个项目里踩过的坑

我把实际调试中比较典型的坑整理成一张表,每一个都是真实遇到过、并且花时间排查过的。

问题表现根因解决办法
前端请求后端跨域失败开发环境Vite代理未配置vite.config.js里配置server.proxy,changeOrigin为true
上传视频返回413Nginx默认上传体积限制1Mlocation块加client_max_body_size 100m
游客端m3u8视频黑屏MinIO bucket跨域未配置在MinIO bucket policy里增加CORS规则,允许GET和HEAD
动态路由刷新后空白刷新时pinia状态丢失,路由表未重放在路由守卫里判断hasRoute,重新addRoute后重导航
订单超时释放库存方向错了定时任务只处理了Redis,没处理数据库先关订单,再还Redis预扣,最后更新数据库
腾讯地图某些点位偏移坐标系不一致统一使用GCJ-02坐标系,后端存储时清洗一次
自定义启动端口不生效IDEA配置的application.yml和启动参数冲突以启动参数优先,统一在application.yml保留一个来源
Redis反序列化报错用了默认JDK序列化,实体类没有Serializable统一改用JSON序列化方式,配置GenericJackson2JsonRedisSerializer

6.3 分时段预约高峰流量的一个真实教训

上线后第一次遇到真正的考验是五一假期。头一天下午,预约量突然暴涨,运营同事在后台看到某个时段的库存从200直接跳到0,游客端反馈"明明显示有票,点提交就提示已约满"。

排查链路是这样的:先看Redis里这个时段的key,发现剩余额度还显示是负数,说明预扣已经发生了;再看数据库订单表,发现有一批订单处于"待支付"状态,是用户卡在支付页没有完成支付;继续追踪日志,发现这些订单的支付回调都正常返回了,但库存的释放逻辑没有触发——因为我在定时任务里只扫描"超时未支付"的订单去释放Redis,而用户主动取消支付、关闭支付页这些动作产生的事件,后端没有处理。

这个问题暴露的是事件覆盖不全,不是并发方案本身的缺陷。修复方案是在支付回调、超时关闭、用户主动取消三个入口统一调用一个 releaseStockQuota 方法,释放Redis预扣。数据库库存不释放,因为那部分额度从未被真正占用。之后类似的流量高峰我再用这套逻辑压测,没有再出现额度不一致。

最后再聊点我的真实体会

这类项目做完回头看,最容易被低估的其实是数据建模和并发控制,而不是框架本身。Spring Boot和Vue的语法、API文档、示例代码都太丰富了,照着写出来不难,难的是把古城景区这个业务场景抽象成一张张结构合理的表,再让这些表在高并发下保持数据一致。我的建议是动手前先花两天把业务流程理清楚:谁在什么时间什么地点买了什么票、票怎么核销、退款怎么处理、报表怎么给运营看。这些业务问题如果回答不清楚,代码写得再漂亮上线也是灾难。

这个系统目前还在持续迭代,后续我计划加两块内容:一是客流的实时热力图,结合景点闸机数据和地图API展示人流密度,帮助运营提前疏导;二是AR扫码识别古建筑,游客用手机对着建筑扫一扫,就能弹出它的年代、功能和历史故事,这算是古城类景区天然适合的数字化方向。回到技术本身还是那句话——业务驱动架构,不要为了技术而技术。希望这份拆解能帮正在做类似项目的人少走几个弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 4:00:55

H3C交换机排障命令实战:从状态解码到根因定位

1. 这不是命令手册&#xff0c;是H3C交换机现场排障的“肌肉记忆”你手边正摆着一台H3C S5130S-28P-PWR&#xff0c;控制台线插好了&#xff0c;串口工具打开&#xff0c;光标在<H3C>后面一闪一闪——但你卡住了。不是记不住display ip interface brief&#xff0c;而是刚…

作者头像 李华
网站建设 2026/10/2 3:59:59

删掉 80% 的 Claude Code Skill 后,AI 编程效率反而更高了

1. 我先说结论&#xff1a;只留下那些“被调用过”的技能三个月前我给 Claude Code 装了 30 多个 Skill&#xff0c;几乎每天都在装新的&#xff1b;三个月后我删掉了 80%&#xff0c;而 Claude Code 反而变得比之前更顺手。很多朋友一听到 Skill&#xff0c;第一反应是给 Clau…

作者头像 李华
网站建设 2026/10/2 3:59:32

Java Playwright自动化测试:单选与复选按钮操作实战

最近在搞自动化测试&#xff0c;用Java配合Playwright操作页面上的单选和多选按钮&#xff0c;踩了一堆坑&#xff0c;也总结了不少经验。这篇笔记是《刚刚问世》系列的开篇&#xff0c;专门讲讲如何用Playwright可靠地点击和验证这类控件。如果你是刚入门Java自动化、或者从Se…

作者头像 李华
网站建设 2026/10/2 3:59:19

水下鱼检测数据集FISHES-IN-THE-WILD-YOLOv5实战指南

简介&#xff1a;本资源是面向计算机视觉开发者与深度学习初学者的YOLOv5鱼类目标检测专用数据集&#xff0c;聚焦野生水下环境中的多类别鱼类识别任务&#xff0c;适用于渔业智能监测、水生生物多样性研究及AI教学实践等场景。压缩包共2321个文件&#xff0c;含1156张带标注的…

作者头像 李华
网站建设 2026/10/2 3:59:12

电商评论情感分析实战:Word2Vec+SVM轻量方案

简介&#xff1a;本资源是一套面向Python初学者与NLP入门者的电商评论情感分析实战项目&#xff0c;聚焦自然语言处理中的文本分类任务&#xff0c;帮助开发者掌握Word2Vec词向量建模与SVM监督学习的端到端实现。资源包含18个文件&#xff0c;涵盖6个CSV格式的正负样本及训练/测…

作者头像 李华
网站建设 2026/10/2 3:58:29

Array.from、Array.of、new Array 三种数组创建方法的核心区别与选型

1. 引言&#xff1a;三个方法都在创建数组&#xff0c;行为却天差地别先说一个真实场景。之前在公司做代码评审&#xff0c;有位同事写了一段new Array(3).map(() > 0)&#xff0c;本意是想要一个长度为 3、元素全是 0 的数组。结果运行之后&#xff0c;数组还是空壳子&…

作者头像 李华