SpringBoot公共自行车租赁管理系统:从业务设计到并发实战解析

发布时间:2026/10/11 2:20:47
SpringBoot公共自行车租赁管理系统:从业务设计到并发实战解析
去年帮一个学弟琢磨毕业设计选题的时候我们最初盯上的是“基于深度学习的智能推荐系统”纠结了两天发现数据不好搞、模型训练效果也玄最后老老实实转回信息管理系统。来回比较了好几个题目最终定下来的就是springboot公共自行车在线租赁管理系统。这个题目切口不大业务却很完整注册登录、站点管理、车辆状态、租车还车、计费结算、故障上报、运营报表几乎把一套线上租赁业务里能想到的环节全串起来了。对想稳稳当当完成毕设、又不想做成纯CRUD流水账的同学来说它是那种“看得见、摸得着、讲得出”的项目。下面我就从选题、技术选型、数据库设计、核心业务流程到实测踩坑把这个系统完整拆一遍。1. 选题定调为什么公共自行车租赁管理系统是本科毕设的甜区1.1 一个被毙掉的智能推荐题引发的思考学弟最初的想法是做一个“骑行路线智能推荐”的题目听着很唬人但聊了两轮就发现不对劲。做推荐系统首先得有几万条用户骑行行为数据这个数据从哪来是个大问题。自己造数据评委一眼就能看出来是模拟的去网上找公开数据集又很难匹配到公共自行车这个场景。更重要的是推荐系统好不好用短期内没有直观的评判标准。毕设答辩只有十几分钟总不能跟老师说“这个模型再训练两个小时效果会更好”。这种不可控的风险放在毕业设计里非常致命。后来我们反过来想毕设选题的核心诉求到底是什么应该是业务完整、技术落地、演示直观、能讲清楚。把这句话作为筛选标准公共自行车租赁管理系统几乎是立刻就浮出来了。1.2 公共自行车业务的“闭环甜区”为什么说这个题目处在本科毕设的甜区我总结了几点需求真实不靠编。公共自行车几乎人人都骑过站点的“有空桩”“有车可借”这些概念受众零理解成本。你自己就是用户需求根本不用编。数据模型有讲究。这个系统不是简单的单表CRUD它有明显的实体关系用户、站点、车辆、订单、计费规则、故障记录。数据库设计能做出一张像样的ER图这本身就是评分点。技术挑战点可控。系统里确实存在并发问题比如同一辆车被两个人同时租走、还车时站点桩位已满这些场景既能体现水平又不至于难到做不出来。成果可视化强。后台管理界面、站点分布地图、按日/按月统计的订单报表都是答辩时能直接投屏展示的硬货。当然有一个前提我得说清楚如果你只做了“增删改查”就交上去这个题目也会显得很平庸。区别就在于核心业务链路租车、还车、计费有没有做过深入的并发和状态设计这是拉开档次的关键。1.3 把题目落到具体县城时的本地化处理选题确定之后题目里还有个地名比如“某县城公共自行车在线租赁管理系统”。这里我不建议直接把真实地名和真实站点数据照搬进演示系统一个是真实地理数据涉及行政边界和地图精度问题没必要给自己惹麻烦另一个是真实站点的经纬度坐标不一定方便获取。实际操作中我用的是“某县城”这样的代称站点列表用“市民广场站”“县医院站”“客运中心站”“政务服务中心站”这种通用命名经纬度则按县城范围的大致坐标附近手动设置一批模拟值。这样演示的时候地图上站点分布依然很直观评委也不会纠结地名真伪安全又省事。2. 技术栈定型Spring Boot 生态怎么选才不给自己挖坑2.1 为什么是 Spring Boot MyBatis Plus技术选型这件事毕设和真实项目有个本质区别毕设有时间上限通常是三个月到半年所以一切选择都要优先考虑“确定性”。Spring Boot 在这个题目里几乎是标准答案。它不是性能最好的框架但它是资料最全、出问题最好搜的框架。你写一个报错信息丢进搜索引擎能翻出几百条解决记录这就叫确定性。对比之下如果用很冷门的框架或者过于前沿的微服务架构遇到一个问题查半天心态容易崩。持久层我选的是MyBatis Plus而不是原生 MyBatis。理由很朴素单表 CRUD 不用自己写 SQL内置分页插件、逻辑删除、自动填充毕业设计里最花时间的“增删改查”部分被它压缩到很短的周期。同时它又不限制你写自定义 SQL订单统计、站点周转率这些复杂查询照样可以在 XML 或者注解里手写。对比 JPAMyBatis Plus 对 SQL 可控性更强对刚接触企业级开发的同学也更亲切。2.2 需要提前想清楚的三个技术选择第一个是前后端分离还是服务端渲染。我的建议是前后端分离后端只做 REST API前端用 Vue 3 Element Plus 搭后台管理界面。理由也很实际现在的教学资源和网上项目基本都是前后端分离的出了问题好查而且答辩的时候你可以一边演示前端页面一边说后端接口设计展示层次更丰富。不要用 JSP时代变了JSP 项目在答辩时很容易被追问“为什么还在用老技术”。第二个是Redis 要不要引入。要而且要用在刀刃上。在这个项目里Redis 不是摆设它承担两个职责车辆租用时的分布式锁、站点车辆列表的缓存。这两个点都是可以写进论文、答辩时讲清楚的技术亮点。如果整个项目没有 Redis就会回到纯 MySQL 单机状态后面提到的并发问题就没有好的解决方案了。第三个是JWT 还是 Session。我选了 JWT。Session 需要维护服务端状态前后端分离场景下还要处理跨域 Cookie 问题JWT 天然无状态把用户ID和角色塞进 Token 里后端拦截器解析即可。唯一要注意的是 Token 过期策略学生管理系统不需要做刷新机制设个 24 小时过期完全够用。2.3 项目骨架与后端目录划分目录结构其实没什么神秘的按主流习惯来就行。下面这个结构是我实际用的清晰而且适合写论文画架构图com.example.bike ├── BikeApplication.java ├── common │ ├── Result.java // 统一返回体 │ ├── BizException.java // 业务异常 │ └── GlobalExceptionHandler.java ├── config │ ├── RedisConfig.java │ └── WebMvcConfig.java // 拦截器注册 ├── controller │ ├── UserController.java │ ├── BikeController.java │ ├── OrderController.java │ └── AdminController.java ├── service │ ├── RentService.java // 租还车核心业务 │ └── StatisticsService.java ├── mapper │ ├── UserMapper.java │ ├── BikeMapper.java │ └── OrderMapper.java └── entity ├── User.java ├── Bike.java ├── Site.java └── Order.java2.4 关键配置application.yml 里容易被忽视的两处配置方面大部分内容都是常规的但有两点经验值得分享。第一点数据库连接池参数。默认的 HikariCP 参数对毕设系统来说没问题但我建议手动指定时区否则高版本的 MySQL 驱动会报时区错误。连接池的核心配置spring: datasource: url: jdbc:mysql://localhost:3306/bike_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379第二点接口统一前缀。所有后端接口都放在/api前缀下前后端分离时前端代理只需要配一个前缀就行跨域问题也会少很多。server: port: 8080 servlet: context-path: /api3. 数据库设计站点、车辆、订单三张核心表如何撑起整个业务3.1 核心表结构设计这个系统的数据库最核心的其实是四张表用户表、站点表、车辆表、订单表。再加两张辅助表计费规则表和故障记录表。我先把核心表的设计贴出来字段不一定全但方向上足够覆盖业务。用户表bike_user字段类型说明idbigint主键自增usernamevarchar(50)登录名唯一索引passwordvarchar(100)BCrypt加密存储phonevarchar(20)手机号balancedecimal(10,2)账户余额roletinyint0普通用户 1调度员 2管理员statustinyint0正常 1禁用create_timedatetime注册时间站点表bike_site字段类型说明idbigint主键site_namevarchar(100)站点名称addressvarchar(200)详细地址longitudedecimal(10,6)经度latitudedecimal(10,6)纬度dock_countint桩位数statustinyint0正常 1维护中车辆表bike_info避免使用bike这种容易被系统保留的名称字段类型说明idbigint主键bike_novarchar(20)车辆编号唯一site_idbigint当前所在站点statustinyint0空闲 1骑行中 2故障 3停用create_timedatetime投放时间订单表rental_order字段类型说明idbigint主键order_novarchar(32)业务订单号唯一索引user_idbigint用户IDbike_idbigint车辆IDstart_site_idbigint借车站点end_site_idbigint还车站点start_timedatetime租车时间end_timedatetime还车时间amountdecimal(10,2)费用statustinyint0租用中 1已完成 2已取消 3异常3.2 车辆状态机和订单状态机这是最容易写出彩的设计点很多同学的“状态”字段只有一个 int然后代码里到处用魔法数字判断容易乱。我当时做完第一版就是这个状态被导师一针见血地指出你这不是状态机只是状态属性。后来我把车辆和订单的状态流转画成了一张明确的状态图写进了论文里。车辆状态空闲 → 骑行中用户租车成功。骑行中 → 空闲用户正常还车。空闲 → 故障用户上报故障或管理员标记故障。故障 → 空闲调度员维修完成并重新投放。任何状态 → 停用管理员下架车辆。订单状态已创建 → 骑行中用户确认租车。骑行中 → 已完成还车并结算成功。骑行中 → 已取消超时未还车或异常介入。已创建 → 已取消支付/创建失败。把这个状态机落进代码核心是每一次状态变更都必须校验前置状态而不是直接 setStatus。比如用户还车时如果订单已经处于“已取消”状态系统就不能允许还车。状态机设计得当后面所有并发问题都会变得很有条理。3.3 索引与逻辑外键的取舍索引这块我踩过一次坑。最初订单表只有主键在测试阶段没什么感觉后来造了5万条演示数据按用户查历史订单的页面直接卡出 1 秒以上的延迟。后来加了三个索引问题就消失了ALTER TABLE rental_order ADD INDEX idx_user_time (user_id, start_time); ALTER TABLE rental_order ADD INDEX idx_site_time (start_site_id, start_time); ALTER TABLE rental_order ADD INDEX idx_order_no (order_no);关于外键我的结论是不要用物理外键用逻辑外键。MyBatis Plus 的实体里加一个TableId和普通字段即可关联关系在代码层面维护。物理外键在删除站点时会产生各种约束冲突毕设阶段数据是脚本生成的根本没有脏数据问题用物理外键只会给自己添乱。3.4 演示数据怎么造才不显得假这一条算是经验之谈。有的同学喜欢手敲十几条数据然后保存截图这看起来太单薄。我用 Java 写了一个启动时的初始化组件一次性生成 10 个站点、300 辆车、500 个用户、2 万条订单。站点名称用“市民广场站”“客运中心站”这类通用命名订单时间分散在近三个月金额按照计费规则生成看起来非常真实。脚本的核心逻辑就是一个循环加随机数重点是把车辆状态和订单状态关联起来比如随机选择 80% 的车辆为“空闲”5% 为“故障”剩下的在“骑行中”这样地图展示和统计报表才自然。4. 核心业务链路租车、还车、计费这三大关怎么打通4.1 租车流程从选车到生成订单的完整时序租车是整个系统里最核心的链路它从用户选择车辆开始到订单创建、车辆状态变更结束。我把流程拆成下面这几步用户进入站点详情查看该站点的空闲车辆列表。选择一辆空闲车辆点击“租车”。后端校验用户状态和余额余额为负是否被封禁。后端校验车辆状态必须为“空闲”。在 Redis 中加分布式锁防止并发请求同时租到同一辆车。创建订单状态置为“骑行中”。修改车辆状态为“骑行中”清空所在站点。释放锁返回订单号。第 5 步是整个流程的灵魂。如果没有这把锁两个人同时点击租同一辆车两个请求都通过了第 4 步的状态校验就会产生两条有效订单但车只有一辆。这在真实项目中叫超卖扫码租车场景下同样存在。4.2 关键代码状态校验与订单创建的事务边界租车逻辑的核心代码我贴一段简化版注释写得比较细Override Transactional(rollbackFor Exception.class) public RentResult rentBike(RentRequest req) { Long bikeId req.getBikeId(); // 1. Redis分布式锁防止并发租同一辆车 boolean locked redisTemplate.opsForValue() .setIfAbsent(lock:bike: bikeId, 1, 5, TimeUnit.SECONDS); if (!locked) { throw new BizException(车辆正在被其他人租用请稍后再试); } try { // 2. 查询车辆状态必须是空闲 Bike bike bikeMapper.selectById(bikeId); if (bike null || bike.getStatus() ! BIKE_STATUS_IDLE) { throw new BizException(车辆不可租用); } // 3. 创建订单 Order order new Order(); order.setOrderNo(OrderNoGenerator.next()); order.setUserId(req.getUserId()); order.setBikeId(bikeId); order.setStartSiteId(req.getSiteId()); order.setStartTime(LocalDateTime.now()); order.setStatus(ORDER_STATUS_RENTING); orderMapper.insert(order); // 4. 更新车辆状态 bike.setStatus(BIKE_STATUS_RIDING); bike.setSiteId(null); bikeMapper.updateById(bike); return new RentResult(order.getOrderNo()); } finally { redisTemplate.delete(lock:bike: bikeId); } }这段代码有三个值得注意的点。第一Transactional保证了订单创建和车辆状态修改要么同时成功要么同时回滚。第二锁必须在事务开始前获取而不是在事务内部获取否则并发请求可能都在等待锁之前就已经把数据读进内存了。第三finally里释放锁是必须的因为一旦中间抛出业务异常锁不释放就会造成后续租车直接失败。这里还要提醒一个细节如果请求的 vehicleId 不存在分布式锁也可以正常加上但事务内抛异常后锁在 finally 中依然会释放不会造成锁死所以这种写法是安全的。关键是 Redis 锁的过期时间要设置合理业务操作通常都在 1 秒内完成5 秒过期足够即使极端情况进程死锁锁也会自动失效不会永久阻塞。4.3 还车结算分段计费规则怎么计算还车流程比租车稍复杂一点因为它涉及到计费。我采用的计费规则是行业里很常见的方案免费时长30 分钟超过免费时长后每 30 分钟 1 元不足 30 分钟按 30 分钟计算单日封顶15 元跨天订单按实际天数累加费用但每日单独封顶这个规则在实现上有几个细节要注意。按“不足 30 分钟按 30 分钟计费”意味着要计算duration与 30 分钟的向上取整天数我写了一个工具方法public BigDecimal calcAmount(LocalDateTime start, LocalDateTime end) { long minutes Duration.between(start, end).toMinutes(); // 免费30分钟 if (minutes 30) { return BigDecimal.ZERO; } // 先计算免费时段后的分钟数 long paidMinutes minutes - 30; // 每30分钟1元向上取整 long units (paidMinutes 29) / 30; BigDecimal amount BigDecimal.valueOf(units); // 单日封顶15元 if (amount.compareTo(new BigDecimal(15)) 0) { return new BigDecimal(15); } return amount; }跨天的情况比如用户在 23:50 租车第二天 00:20 还车总共 30 分钟按上述规则应该是免费的但系统判断会有点混乱。我在实际实现时做了一个简化订单金额在还车计算时只按“租车时长是否跨天”判断是否重置当日封顶如果跨天前一天的账单和当天的账单分开计算。这个需求在真实运营里很常见做进系统后答辩时讲出来也是一个亮点。4.4 并发还车同一个站点桩位满了怎么办还车时还有一个并发场景用户 A 和用户 B 同时骑车到同一个站点该站点剩余桩位只有 1 个但两人的还车请求同时到达。如果程序只判断“剩余桩位 0”两人都会还车成功实际就会多出 1 辆车没有桩位可锁。我的做法是还车时同样用 Redis 锁锁的 key 是站点 ID。先扣减站点可用桩位dock_count - 当前车辆数再更新订单和车辆状态。这个扣减操作放在事务内部通过 SQL 的条件更新保证原子性UPDATE bike_site SET current_bike_count current_bike_count 1 WHERE id #{siteId} AND current_bike_count dock_count当update影响行数为 0 时说明桩位已满直接抛出“该站点桩位已满请前往附近站点还车”的异常。这个方案把并发控制落到了数据库的行锁层面比纯 Redis 锁更可靠因为即使 Redis 锁因为网络问题失效数据库的条件更新依然能拦住错误操作。5. 权限体系与运营后台三角色视图怎么构建5.1 基于 JWT 的三层权限控制系统里我把角色分成三种管理员ADMIN、调度员DISPATCHER、普通用户USER。权限设计上不搞太复杂的 RBAC 表直接在 JWT 的claims里带上role字段后端用拦截器统一校验。JWT 生成与解析的核心思路// 登录成功签发token String token Jwts.builder() .setSubject(userId.toString()) .claim(role, user.getRole()) .claim(username, user.getUsername()) .setExpiration(new Date(System.currentTimeMillis() 86400000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();后端拦截器做的事情也简单先从请求头取Authorization解析 token拿到用户ID和角色放入ThreadLocal里的用户上下文然后根据接口注解判断角色是否匹配。这样管理员接口、调度员接口和用户接口就能分离开。前端路由再配合meta.role字段控制页面可见性双端校验答辩时能讲得很清楚。5.2 调度员处理故障车的闭环流程故障处理是我在这个项目里比较得意的一个设计。用户还车时如果发现车辆有损坏可以上报故障系统把车辆状态置为“故障”同时在故障记录表里插入一条记录关联订单号和车辆编号。调度员登录后台后可以看到待处理故障列表点击“接单”后故障记录状态变为“处理中”处理完成的闭环是调度员将车辆标记为“维修完成”此时车辆状态恢复到“空闲”站点重新关联。这个闭环的好处是车流、信息流、状态流是统一的不需要额外的表格去维护“这辆车从哪来、到哪去”。5.3 运营报表答辩现场最实用的加分项很多同学不重视报表觉得就是几个柱状图但这恰恰是答辩展示时观众最容易看懂的部分。我实现了三个统计维度日租借量趋势、站点热度排行、车辆周转率。站点热度排行SQL是典型的联表分组复杂度适中展示效果好SELECT s.site_name, COUNT(o.id) AS rent_count FROM rental_order o LEFT JOIN bike_site s ON o.start_site_id s.id WHERE o.start_time BETWEEN #{begin} AND #{end} GROUP BY o.start_site_id ORDER BY rent_count DESC LIMIT 10;前端用 ECharts 画柱状图和折线图。答辩演示时切到这个页面点一下“本月数据”图表刷出来比干巴巴的表格数据有说服力得多。做的时候唯一要注意的是rental_order表的主键索引必须建好否则按时间范围查询几万条数据还是会慢。6. 实测踩坑记录从开发联调到答辩的完整排错链路6.1 坑一还车时间跨天导致计费金额异常这个 bug 是在联调阶段发现的。用户晚上 23:50 租车第二天凌晨 00:20 还车系统算出来的金额竟然是 0 元但按要求应该收取费用因为订单时长已达 30 分钟且第二个计费区间刚进入。排查过程是这样的首先我看代码里的calcAmount方法单独测试正常传入 30 分钟返回 0。那问题一定出在endTime上。打印日志发现endTime是LocalDateTime.now()但租车时间startTime是数据库返回的时间两者时区不同导致计算出来的Duration变成了 29 分钟。根因清楚了MyBatis 插入数据时如果实体中startTime用的是LocalDateTimeMySQL 驱动会把时间按Asia/Shanghai处理但应用服务获取now()时默认用的是 JVM 时区如果 JVM 时区配置不当就会出现这种隐性偏差。修复方式很简单在启动类里强制指定时区PostConstruct void setDefaultTimezone() { TimeZone.setDefault(TimeZone.getTimeZone(Asia/Shanghai)); }6.2 坑二同一辆车被两个人同时租走这个 bug 是我在压测时发现的也是整个系统里最重要的一次修复。最开始我的租车逻辑很简单先查车辆状态是不是“空闲”是则更新为“骑行中”创建订单。但两个并发请求同时查到“空闲”状态然后同时更新就会出现一条车被两条订单租走的情况。修复方案用了两级保险。第一级是 Redis 分布式锁前面代码里已经体现第二级是数据库层的乐观锁也就是在更新车辆状态时带上条件// 使用条件更新防止并发 int rows bikeMapper.updateStatusWithCondition( bikeId, BIKE_STATUS_IDLE, // where status 0 BIKE_STATUS_RIDING // set status 1 ); if (rows 0) { throw new BizException(车辆已被租用); }这里有个经验Redis 锁解决的是应用层的串行化数据库条件更新解决的是数据一致性的底线。两层都做才能在答辩时理直气壮地说“我考虑到了并发”。6.3 坑三车辆列表缓存和数据库状态不一致项目后期我为了提升站点车辆列表的响应速度把站点车辆数缓存到了 Rediskey 是site_vehicle_count_1001用户浏览站点时先读缓存。结果测试时发现还车后站点车辆数没有变化刷新页面还是旧数字。排查过程是这样的还车服务里更新了 MySQL 的站点车辆数但漏掉了删除 Redis 缓存这一步。修复也不复杂每次车辆状态变更时把对应站点的缓存 key 删掉等下次查询时再回填。这个操作叫Cache Aside Pattern是缓存一致性里最基础也最实用的模式。需要注意的是删除缓存和更新数据库的顺序很重要正确做法是先更新数据库再删除缓存这样即使删除失败下次读取时也能回源数据库避免读到脏数据。6.4 答辩时最容易被问到的技术问题根据我自己的答辩经历和被导师模拟提问的经验总结了几个高频问题提前准备好现场就不会卡壳。问题一订单号是怎么生成的为什么不用数据库自增 ID答数据库自增 ID 是单库单表的方案一旦分库分表或并发量上来就会有冲突。所以我用“日期 随机数 用户ID后四位”的方式生成业务订单号保证唯一性和可读性同时保留主键自增给数据库内部排序用。可以再补一句真实场景一般会用雪花算法或发号器但毕设里这个方案已经够用。问题二分布式锁的 key 过期了怎么办答我设置了 5 秒过期正常情况下业务操作远小于 5 秒。如果因为网络或 GC 停顿导致锁过期数据库层面的条件更新会在最后兜底不会产生超卖。这种“防重入 数据库兜底”的组合思路评委是认可的。问题三事务和锁的顺序为什么是先锁后事务答如果先开事务再获取锁A 请求拿到锁但事务还没提交B 请求查询到的车辆状态仍然是旧值就无法达到串行化的效果。先获取锁再开启事务才能保证同一个车的租用操作是真正排队执行的。不过这样会稍微缩短锁的持有时间减少阻塞。最后再分享两点个人体会做完这个系统回头再看它最大的价值不是把所有功能写出来而是让我走完了一条完整业务线并且想清楚每个环节的数据是怎么流动的。如果你也打算做这个方向我有两个建议。第一把重心压在租还车链路和计费正确性上。这两个点做扎实答辩时你可以主动讲很多反之如果只是在管理后台增加几个页面评委很容易觉得你没有深入思考。第二演示数据一定要有质感。站点名称用通用命名、订单时间分布在近三个月、金额符合计费规则这些细节决定了演示效果的上限。数据造得越像是真实运营场景评委就越容易把你的系统当成一个“能跑的东西”而不是“交差的作品”。如果你时间紧张我的建议是两周搭骨架、两周做核心流程、一周做报表和演示数据、最后一周专门准备答辩问题清单。这套节奏我实测下来是可行的祝你的毕设一切顺利。