Spring Boot在线电影购票系统:从数据库设计到并发控制实战
1. 先想清楚这个购票系统到底要做什么如果只是一口气把代码敲完那它可能只是个“CRUD练习”但既然项目标题里写了在线电影购票又基于Spring Boot背后其实是一整套完整的电商交易闭环。我在帮别人梳理这类项目时第一件事永远是拉出业务链路用户从注册登录到选座下单再到支付出票管理员从排片到统计票房中间每一步都是可以展示能力的技术点。这个系统的受众很明确正在准备毕业设计的学生、想找Java后端实习的开发者或者纯粹想用Spring Boot练手的人。它不是什么高不可攀的架构但做好了足够体现你对业务的理解深度和对并发、事务、缓存这些核心概念的掌握程度。先说清楚一个标准在线电影购票系统通常包含两条主链路。用户侧注册登录、浏览影片列表、查看影片详情、按日期/影院筛选场次、选择座位、提交订单、模拟支付、查看历史订单信息。管理员侧维护影片信息、管理影厅和场次、处理订单状态、查看基础统计数据。别小看这些模块把每一个都做到能稳定跑、能演示、能回答追问难度并不低。我见过最多的失败案例不是代码写不出来而是“做了个玩具”——页面能打开但并发下单直接超卖数据库表设计得改三轮答辩时被评委问一句“你这个座位状态怎么保持一致性”就卡住。这篇内容我尽量把从设计到部署的整个链路讲透每个环节都给到可以直接照抄的思路和参数而不是泛泛而谈。2. 数据库设计一张订单表怎么支撑整套购票逻辑2.1 核心表结构与字段细节建表是整个项目的基石表结构一旦定错后面代码写再多都是白费。我做这类系统时核心表基本固定在五张用户表、影片表、影厅表、场次表、订单表。有些项目会把影厅和场次合并我建议拆开因为一个影厅一天会排多个场次合并容易产生数据冗余。用户表字段比较常规主键id、用户名、密码、昵称、手机号、角色标识、创建时间。密码字段必须用加密存储我推荐用加密算法处理密码而不是标准MD5或明文。有人会问为什么不能直接MD5MD5加固定盐也可以但在真实项目中带自适应成本的加密算法更稳妥因为现代的图形处理器算MD5太快了暴力碰撞成本极低。Spring Security自带的BCryptPasswordEncoder几行就接入没必要自己造轮子。影片表要注意的字段包括影片标题、海报URL、导演、主演、时长、上映日期、影片简介、状态。海报这个字段很容易被忽略很多新手把它设计成上传图片文件本身存到数据库里这是典型的坏味道。正确做法是上传的图片保存到服务器某个目录或对象存储数据库里只存访问路径URL字符串。否则随着影片数量增加数据库体积会膨胀得很夸张。影厅表和场次表是绑定关系。影厅表存储名称、座位行数、座位列数。场次表是业务核心字段包括所属影片id、所属影厅id、开始时间、结束时间、票价、座位状态快照。这里就暴露了第一个设计分歧座位状态是动态计算还是快照存储。我的经验是不要为每个座位建一张几千行的大表而是采用场次表中的“座位状态快照”字段用JSON字符串存储例如初始化时生成“A1-0,A2-0,A3-1”这样的结构0代表可售1代表已售或者锁定。这样查询场次时一次拿回座位矩阵非常快下单时再进行行级更新。订单表是交易的最终落点订单号、用户id、场次id、座位信息、订单金额、状态、下单时间、支付时间。这里有一条铁的纪律金额字段必须用DECIMAL类型比如DECIMAL(10,2)千万别用double或float。因为浮点数在二进制里表示不精确涉及金额累计计算时会出现0.10.2不等于0.3的尴尬情况这在金融场景是绝对忌讳的。我整理了一个最小建表清单大家在设计阶段可以参考这些字段和类型表名核心字段类型建议备注用户表主键id、用户名、密码BIGINT、VARCHAR(50)、VARCHAR(100)用户名唯一索引影片表主键id、标题、海报、时长BIGINT、VARCHAR(100)、VARCHAR(255)、INT状态字段区分上映和下架影厅表主键id、名称、座位行/列数BIGINT、VARCHAR(50)、INT、INT行和列决定总座位数场次表主键id、影片id、影厅id、开始时间、票价、座位快照BIGINT、BIGINT、BIGINT、DATETIME、DECIMAL(10,2)、TEXT座位快照需要设计锁定标记订单表主键id、订单号、用户id、场次id、座位信息、金额、状态BIGINT、BIGINT、BIGINT、BIGINT、VARCHAR(255)、DECIMAL(10,2)、TINYINT订单号加唯一索引2.2 字段设计里那些说不出口的坑表结构里藏着不少细节是开发中后期才冒出来的坑。第一个坑字符集排序规则。建库时一定要用utf8mb4而不是utf8。为什么因为utf8mb4才是真正的四字节编码支持所有Unicode字符包括生僻字和emoji。虽然我们开发中不建议使用emoji但界面上的特殊符号、影片名里的特殊字符都有可能触发存储异常。MySQL里utf8mb4的默认排序规则utf8mb4_general_ci或者utf8mb4_unicode_ci都可以前者更快后者更准确量级不大时选哪个都不影响。第二个坑时间字段的时区问题。如果你用MyBatis或MyBatis-Plus数据库连接串必须在末尾追加serverTimezoneAsia/Shanghai否则返回的时间比本地时间少8个小时。这个坑我记不清踩了多少次本地开发时区的配置前缀配好了一部署到云服务器数据库在另一台机器上时区不一致订单创建时间全乱了。第三个坑订单号的生成策略。我有段时间图省事直接用了时间戳结果同一秒内两个订单撞了唯一索引。虽然概率低但撞一次就是一次线上事故。推荐做法是时间戳加随机数拼接或者干脆引入雪花算法。对单体项目来讲用Java的UUID去掉横杠再截取一段配合订单ID回查基本够用。但要注意订单号是需要展示给用户的纯UUID不友好所以通常是把时间格式化成yyyyMMddHHmmss再拼一个随机数。第四个坑逻辑外键还是物理外键。对于毕业设计级别的项目我倾向不建物理外键约束。原因很现实物理外键在增删数据时会产生强约束检查影响写入性能同时在校验“某场次已被删除但订单还在”这类场景时逻辑外键更灵活。建表时只加普通索引业务层从代码上保证引用完整性即可。面试被问到就讲得清楚单体项目里逻辑外键加事务控制比物理外键更利于性能和解耦。2.3 初始化数据的组织方式很多同学的数据库脚本就是一堆建表语句里面没有任何初始数据。但这会直接影响演示体验。你想想答辩当天评委要求你登录管理员账号看数据统计你慌慌张张现场注册一个管理员账号再手动插入几条影片记录观感立刻掉一个档次。所以数据库脚本里至少要有三块内容第一建表语句第二内嵌的影片、影厅、场次初始数据比如安排三部上映影片、两个影厅、未来三天的场次第三一个管理员账号和一个普通用户账号密码都要是加密后的密文而不是明文。这样导入数据库后系统可以直接演示不用现造数据。另外我习惯把数据库脚本拆成三份schema.sql建表、data.sql初始数据、demo.sql演示数据。这样做的好处是出问题时可以单独重置某一层而不是全库重导。脚本里最后还要加上按顺序删除外键依赖的DROP语句保证可以重复执行。3. 核心功能实现从登录到下单的完整链路3.1 用户认证JWT方案还是Session方案从前端页面跑到后端接口第一个要解决的就是登录认证。这个项目用Spring Boot实现绕不开两个主流方案Session和JWT。我自己在这个项目里选了JWT主要原因是前端通常是独立页面不跟后端部署在同一台服务器上前后端分离后Session的跨域处理很麻烦要配Cookie的SameSite属性要处理跨域携带凭证还要考虑分布式环境的Session共享。JWT的思路是用户登录成功后服务端生成一个包含用户身份信息的令牌字符串返回给前端前端每次请求都在请求头里带上这个令牌后端通过拦截器解析认证。这套流程无状态、天然支持跨域、也方便后续做分布式扩展。实操时我用的依赖组合是spring-boot-starter-security或者自己写一个拦截器。为了不让项目复杂度太高我倾向于不用Security全家桶而是手写一个WebMvcConfigurer注册拦截器。核心代码如下public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } // 解析JWT验证签名和过期时间 Claims claims JwtUtil.parseToken(token.substring(7)); if (claims null) { response.setStatus(401); return false; } request.setAttribute(userId, claims.get(userId)); return true; } }JWT有个必须处理的点密钥不能硬编码在类里至少要放到application.yml配置文件里。过期时间我一般设置成24小时太短用户频繁登录很烦太长又增加令牌被盗后的风险。如果做“记住我”功能可以单独签一个长过期时间的令牌并用Redis保存当前有效令牌的版本号支持主动踢人下线。3.2 电影与场次查询分页、缓存与参数校验用户打开首页后第一件事是看影片列表。正常情况下数据量不会太大但查询接口要做得规范最基础的是分页查询。用MyBatis-Plus时分页很好做引入分页插件然后调用Page对象传入当前页和每页条数。有一个细节我单独提醒一下MyBatis-Plus的分页插件必须通过Configuration配置类注入版本不同配置位置也不同。很多人踩过这个坑分页方法调用后返回的总条数是0或者查询出的数据是全量而不是分页数据然后翻Starter源码才发现漏了配置其实异常信息已经提示“Please configure page interceptor”。只要加一个配置类注册MybatisPlusInterceptor添加PaginationInnerInterceptor即可。查询接口一般会有两个场景影片列表和场次列表。影片列表可以加一个简单的Redis缓存key设计成movie:list:page:1value是JSON字符串过期时间设60秒。为什么要60秒而不是更长因为管理员可能会修改影片状态过长的缓存会带来数据不一致。同时把缓存过期时间设置成一个合理的均衡值既减轻数据库压力又不至于让修改长期不生效。极端情况下的正确姿势是“先更新数据库再删除对应缓存key”也就是经典的Cache Aside Pattern。场次查询要按日期筛选。数据库里存的是datetime类型前端传过来“2025-05-01”后端查询时不能直接等值比较而是用BETWEEN。注意MySQL的日期边界问题BETWEEN 2025-05-01 00:00:00 AND 2025-05-01 23:59:59如果不拼时间会漏掉当天的部分场次。我的做法是构造一个LocalDate对象再转换成当天的开始时间和结束时间一个边界都不漏。3.3 选座与下单并发不超卖的底层逻辑这是整个项目技术含量最高的部分也是答辩时评委最爱追问的地方。问题核心是两个用户同时点同一个场次的同一个座位系统怎么保证不会都下单成功业务逻辑先从最简单的方案说起。用户提交订单时无非是带上场次id和座位号后端要做三件事第一步从场次表的座位快照字段里解析出当前座位状态第二步判断目标座位是否可用第三步状态改为锁定生成订单。如果这三步不加任何并发控制两个人同时读到“座位可售”状态就会同时生成订单造成超卖。怎么解决我给三个层次的方案从易到难。第一个方案是数据库行锁。查询场次记录时直接加上SELECT ... FOR UPDATE强制锁定这一行。事务提交前别人读同一行都会被阻塞。这是最简单、最可靠的方式但代价是同一场次的所有座位都被锁住即使买的是A1和B1两个不同座位也会串行。对本项目的并发量来说完全够用而且没有安全问题。第二个方案是乐观锁。在场次表新增一个version字段更新座位快照时检查WHERE version #{oldVersion}如果更新行数为0说明版本已被别人改过事务回滚并提示用户重新选座。这个方案不锁行性能和并发能力都更好但需要开发者在上层增加重试机制否则用户操作体验不好。第三个方案是Redis预占。把座位状态直接维护在Redis里用Lua脚本原子性完成“判断可售标记锁定”。这适合真正的互联网级并发但项目复杂度会明显上升需要一个分布式锁和缓存同步的完整方案。我个人的推荐在这个项目里用第一方案最简单、最稳。看看实际代码Transactional public String createOrder(Long scheduleId, String seatInfo) { Schedule schedule scheduleMapper.selectByIdForUpdate(scheduleId); // 解析seatInfo比如 A1 String[] seatList seatInfo.split(,); for (String seat : seatList) { if (schedule.isSeatLocked(seat)) { throw new BizException(座位已被锁定); } } // 生成座位快照并更新 schedule.lockSeats(seatList); scheduleMapper.updateById(schedule); // 创建订单状态为待支付 Order order Order.createPendingOrder(...); orderMapper.insert(order); return order.getOrderNo(); }还有一个容易被忽略的点事务边界。Transactional注解不能加在private方法上Spring的AOP代理对其无效这是最常见的失效场景。另外在同一个类内部方法自调用也不会走代理所以如果你写了一个普通方法调用带事务的私有方法事务依然不会生效。解决办法是分成两个类或者用注入自身的方式调用。订单状态的设计我建议这样管理待支付、已支付、已取消、已退票。用户下单后如果有15分钟的支付超时限制可以用Spring的延时任务或者Redis的过期监听实现也可以简单在查询时判断下单时间超过15分钟就自动置为已取消。考虑到项目复杂度用查询时判断加定时扫描表兜底是性价比最高的方案。3.4 后台管理角色权限与数据看板管理员的入口通常单独做一套页面。权限控制不用上复杂的权限框架登录返回的用户角色字段就够了。后端对管理接口加一个拦截器判断当前请求用户的角色是否等于管理员不等于直接返回403。数据统计这块最简单的“今日票房”SQL是统计今日已支付订单的金额总和。要注意状态过滤只有状态为“已支付”的订单才算收入待支付和已取消的都不算。同一张票从待支付到已支付是状态机迁移而统计是在状态机末端进行。管理端还需要一个“放映计划”功能也就是给某部影片安排场次。这里有个业务校验很容易遗漏同一影厅在同一时间段不能排两个场次否则座位状态会冲突。后端在创建场次时必须加上时间重叠校验SQL大概是查询同一影厅下开始时间小于新场次结束时间且结束时间大于新场次开始时间的记录数量数量大于0就拒绝创建。4. 实操排坑实录运行、部署中的典型问题与解决思路4.1 项目启动异常与依赖问题Spring Boot项目最常见的启动失败原因有三个。第一是端口被占用默认8080被其他进程抢了改server.port设为8081即可。第二是数据库连接失败检查application.yml里的URL、用户名、密码是否匹配以及数据库是否已经启动。第三是Maven依赖下载缓慢或者失败可以在settings.xml里配置一个国内镜像源这个对新手尤其友好否则卡在下载依赖环节一整天都进不了项目。有个很隐蔽的问题启动时明确报错说缺少某个类的定义比如ClassNotFoundError多半是某个依赖没有引入进来或者版本冲突。排查办法最好用打开IDEA的终端执行mvn dependency:tree看依赖树里有没有冲突。Spring Boot项目最容易出问题的是Lombok版本和Java版本不匹配如果遇到注解不生效优先查这个。4.2 跨域问题前后端分离时的CORS配置我把前端页面放在独立端口后端口又是另一个端口时前端发起请求会报CORS错误。解决办法是后端配置一个WebMvcConfigurer实现addCorsMappings方法允许指定来源、指定请求头和方法。千万别图方便用CrossOrigin加在单个Controller上那只能解决单个接口的跨域还有更多接口要被逐一处理。正确做法是全局CORS配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意使用allowCredentials(true)时allowedOrigin不能用号要用allowedOriginPatterns()。这是我单独试出来的坑很多资料没提到。4.3 上传图书海报后访问不到的问题很多Spring Boot新手上传海报后文件确实保存到了本地目录但浏览器访问却返回404。原因是没有配置静态资源映射。Spring Boot默认只映射classpath下的/static目录你上传的图片保存在了项目运行目录下的某个文件夹不在静态资源扫描范围内。解决办法有两个。最简单的把上传目录直接放在项目的static/upload下面重启后就能访问。但生产环境不推荐因为每次重新部署可能覆盖掉。更专业的做法是配置一个资源映射将/upload/**路径映射到本机磁盘实际目录Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath /); }这个坑尤其影响演示体验一定要在部署前测一遍。4.4 数据库事务与MyBatis-Plus分页的连带问题我碰到过一个很典型的现象开启分页插件后事务方法里执行了两次查询第二次查询结果串到了第一次查询的分页数据集上。原因是MyBatis-Plus分页插件的ThreadLocal在处理嵌套查询时产生了上下文污染。解决方法并不复杂确保分页查询在最外层先执行避免在循环里查询分页数据同时使用Page对象的泛型明确指定实体类型。还有一个高频问题Transactional事务不回滚。常见原因是方法内try...catch捕获了异常导致异常没有传播到事务管理器。记住事务回滚依赖于异常向外抛出像RuntimeException或Error才会被默认捕获捕获吃掉异常等于告诉Spring“一切正常”。5. 文档撰写与答辩准备源码之外的另一半分数5.1 项目文档的章节安排与写作要点系统有了、代码能跑这只能保证你及格。文档写得清楚、答辩答得上才是拉开差距的地方。我辅导过几次毕业设计很多人的文档是把代码注释复制粘贴过去读起来跟记流水账一样评委翻两页就不想看了。一份好的项目文档从需求分析开始就要讲清楚“为什么要做这个系统、目标用户是谁、核心业务流程是什么”。接下来是总体设计用文字加简单的架构分层描述比如表现层、业务层、数据访问层的关系。详细设计阶段每个模块要按“输入、处理、输出”三段式写清楚。数据库设计部分把每张表的字段说明列成表格重点说明外键关系和索引设计。最后是测试不要只写“功能正常”要写测试用例设计正常流程、异常流程、边界值。文档里还有一类内容是很多同学漏掉的非功能需求。比如系统响应时间、并发能力、安全性要求。这些看起来虚但评委很吃这一套说明你有工程意识而不只是写了几个接口。我的建议是文档和代码开发同步进行不要等项目快交付才开始写。今天做了登录模块晚上就把这一章搞定明天做了下单模块晚上就把该模块的时序和流程细节补全。否则到时候连续熬夜赶出来的文档质量和对项目的理解深度一定跟不上代码。5.2 答辩现场的高频问题与应对思路答辩最常见的追问第一个就是“为什么用Spring Boot而不是其他框架”别急着背网上的标准答案——“Spring Boot简化配置”——要说得更具体它内置Tomcat支持自动装配生态成熟可以快速搭建独立运行的微服务应用。重点突出“开发效率”和“生态完整”。第二个高频问题“你怎么解决超卖问题”如果前面我把数据库行锁方案做扎实了就很容易答通过SELECT ... FOR UPDATE锁住场次记录在事务内完成座位状态检查和更新确保并发下单时只有一个事务能成功修改座位状态。评委如果不满意你再补充乐观锁和Redis方案的扩展思路更显得思考层次丰富。第三个高频问题“数据库为什么这样设计”答逻辑要从业务出发比如拆出场次表和影厅表是为了排片灵活订单表独立是为了支持状态变更与数据统计金额用DECIMAL是避免浮点误差。只要从业务需求反推设计评委就能感受到这是你自己思考过的项目而不是抄的。第四个问题是“项目有什么不足”很多人直接被问懵。一定要准备几条真正的不足而不是说“没有不足”。比如支付模块是模拟实现的没接入真实支付渠道系统目前只支持一个普通用户端和一个管理端影厅和院线的多级管理没有扩展缓存只做到了单机版Redis没有处理缓存一致性问题。这个回答反而会给评委留下“他知道自己在做什么”的印象。5.3 部署演示时的软件环境与操作顺序如果答辩需要现场演示我的建议是提前准备好一个打包号版本的jar包并且在本地和云服务器上各跑一遍。用Maven执行mvn clean package -DskipTests打包然后启动java -jar xxx.jar。前提是数据库已经初始化好application.yml里的连接信息指向可用的MySQL实例。演示操作顺序也很重要。首先打开首页展示影片列表点击一部影片查看详情然后切换日期查看场次选择座位下单模拟支付在“我的订单”里看到已支付状态的订单。紧接着切到管理员账号展示影片管理页面新增一部电影发布新场次最后打开数据统计页展示订单数和金额变化。整个过程自然流畅别在没人的时候手忙脚乱地调数据库。答辩前至少自己完整走两遍流程把每个按钮的位置、每段跳转的路径都记熟。如果现场网络不好可以提前把截图放进PPT里作为备用方案不依赖实时演示。我个人在实际辅导项目时反复叮嘱过一句话这个项目做完你收获最大的绝不是那套CRUD代码而是“如何把一个相对复杂的业务流程拆解成可落地的模块再通过技术手段解决其中真实存在的问题”。超卖问题的并发控制、缓存策略的取舍、数据库设计里的字段与索引权衡这些问题想明白一个比记住十个框架注解都管用。在线电影购票系统后续还能往上加不少东西比如基于用户行为的影片推荐、基于场次成交数据的动态定价、更多的订单统计维度哪怕是一个简单的协同过滤算法都会让这个项目在同类作品里明显亮眼。真到了那个阶段你也就不会再去纠结“这个项目值不值得做”这种问题了。