ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java自行车分享平台实战:状态机与并发控制核心解析

Java自行车分享平台实战:状态机与并发控制核心解析 去年接了一个课程设计题目是“基于JAVA的自行车分享平台 骑行爱好者交流平台”。一开始我以为这就是个类似共享单车的管理系统等真正梳理完需求才发现它其实是“共享单车业务 垂直骑友社区”的组合。平台要处理车辆发布、查找、预约、归还还要支撑骑行轨迹记录、骑友圈子、活动召集、帖子评论这些社交功能。换句话说它不是一个简单的CRUD后台而是涉及位置服务、订单状态机、并发控制和实时消息推送的完整项目。这篇文章我把整个实现思路、核心方案和踩过的坑完整拆给你适合正在找Java课程设计、准备简历项目或者想彻底搞懂业务状态机的同学。这个平台能解决什么问题往小了说一个校园或城市社群里的私人自行车经常闲置而另一些人想骑车却找不到车源。平台把车辆信息发布、位置查询、预约取车、归还结算做成闭环让闲置车辆流动起来。往大了说骑行爱好者需要一个聚集地大家分享路线、发帖约伴、活动组队。所以平台不是只做“借车”而是把工具和社区融为一体这也是它能成为经典Java项目选题、并且值得动手复现的原因。1. 项目概述为什么“自行车分享骑友社区”值得做1.1 平台要解决的两个实际问题我梳理了平台的四类角色。第一类是普通骑友注册后可以浏览地图上的车辆、预约用车、发布骑行轨迹、在社区发帖和评论。第二类是车主把自己的自行车挂到平台上设置每小时租金或免费共享等别人预约。第三类是骑行社团管理员可以发起周末活动在后台审核车辆和帖子。第四类是平台管理员处理纠纷、下架违规内容、查看运营数据。四类角色都在同一个系统里权限划分不复杂但业务边界必须清晰。这种 C2C 混合模式非常考验需求梳理能力。如果一开始就把所有功能揉在一起很容易出现“车辆表里混着用户字段、帖子表里混着订单字段”的尴尬设计。所以我的建议是按“业务主线 状态流转”的方式拆解需求先把核心场景想明白再设计表结构最后写代码。这比拿到题目就新建项目写 Controller 要靠谱得多。1.2 这个项目适合谁能学到什么如果你浏览过“java面试题”和“java学习路线”这类内容应该能发现很多问题光靠背八股文说不清楚。比如“数据一致性”“事务失效”“动态代理”“容器选型”在这个项目里都有真实的对应点。做完一遍再去面试你至少能讲出一个自己调过的业务场景而不是只回答定义。对零基础的同学我的建议是先照着整体设计走一遍不要急着写代码。对有一点 Java 基础、想提升的同学重点看第四章的并发和数据一致性这一块是简历和面试里最能体现深度的地方。项目做完你对 Spring 事务、乐观锁、Redis 锁、AOP 的理解会明显不一样。2. 需求拆解与技术选型先用纸面设计取代“想到哪写到哪”2.1 四条核心业务主线与状态流转我把系统拆成四条业务主线用户与权限、车辆管理、骑行履约、社区互动。用户与权限管注册登录、个人资料、积分信用车辆管理管车辆发布、审核、位置、状态骑行履约管预约、取车、归还、结算社区互动管帖子、评论、点赞、活动、站内消息。每条主线内部用一张主表和若干关联表支撑主线之间通过外键或业务字段连接。这里有个最容易错的地方车辆状态不是简单的“空闲/使用中”。我最后定义了五种状态空闲、已预约、骑行中、维护中、已下架。状态流转必须严格按顺序空闲可以到已预约已预约可以到骑行中或回到空闲用户取消骑行中只能到空闲或维护中维护中只能回到空闲。每一步都必须带上原状态条件否则状态机就会乱。订单的生命周期也要定清楚。我设计的状态有待取车、骑行中、已完成、已取消、超时未取自动取消。订单状态和车辆状态不是一对一比如订单到了待取车车辆是已预约用户扫码取车后订单变骑行中车辆也变骑行中。这两个状态必须在一个事务里同时更新避免出现“订单已完成但车辆还显示骑行中”的脏数据。2.2 Java技术栈选型Spring Boot MySQL Redis 够用了后端我选择 Spring Boot 2.7 Java 8。为什么不直接上 Java 17最新 JDK 当然好但很多课程设计和老项目还在 Java 8Spring Boot 2.x 对 Java 8 支持最成熟排查资料最多。如果你个人环境允许也可以使用 Java 17 Spring Boot 3.x代码差异不大但要留意 javax 和 jakarta 命名空间的变化。持久层用 MyBatis-Plus。它的好处是单表 CRUD 不用写 SQL复杂查询又可以手写 XML。社区帖子分页、车辆周边搜索这些操作用 Mapper 注解或 XML 都很直接。Redis 承担四类职责登录 token、车辆列表缓存、分布式锁、点赞计数器。数据库用 MySQL 5.7生产环境建议升到 8.0性能和备份机制都会更好。前端我用 Vue 2 Element UI Axios地图接的是高德地图 JS SDK 和 Web 服务 API。选高德的原因很简单国内使用方便轨迹绘制和逆地理编码文档齐全个人项目免费额度足够。前端只调用自己的后端接口地图 Key 放在服务器环境变量里不要把 Key 暴露到浏览器端。最后说一句为什么不用微服务和消息队列没有那个必要。用户规模几千到几万业务并发并不高单机部署完全够。技术栈堆得越多部署和排查成本越高对个人项目是负担。你要在面试里体现的是在合适的规模里做合适的选择。2.3 数据库表设计状态机落到字段级别核心表我建了七张user、vehicle、bike_order、ride_track、track_point、post、comment。外加一张 message 通知表和一张 operation_log 操作日志表。user 表字段包括 id、username、password、phone、avatar、nickname、credit_score、deleted、create_time、update_time。vehicle 表包括 id、owner_id、title、description、position_lng、position_lat、status、price_per_hour、version、deleted。bike_order 表包括 id、order_no、user_id、vehicle_id、status、start_time、end_time、amount、cancel_reason、del_flag。ride_track 和 track_point 分开存ride_track 存 user_id、distance、duration、start_time、end_timetrack_point 存轨迹经纬度序列按 track_id 和 point_no 排序。comment 表我用 parent_id 做两级回复层级太深会带来查询麻烦。所有表都带 deleted 逻辑删除标记所有关联查询默认加上 delete_flag 0。version 字段只放在需要用乐观锁的表上比如 vehicle 和 bike_order不要每张表都加避免字段膨胀。索引方面车辆表的 status 和 position_lng、position_lat 需要建索引订单表重点在 user_id、vehicle_id、status帖子表按 hot_score 建索引。如果你的 MySQL 支持空间索引周边搜索可以用 ST_Distance_Sphere否则先根据经纬度框一个矩形范围再计算实际距离这个方案在数据量不大时完全够用。3. 核心模块实现从账号到订单从轨迹到社区3.1 用户登录鉴权BCrypt Redis token 替代传统Session用户模块最容易被忽视的是密码和登录态。密码一定不能用明文我使用 BCrypt 加密这是 Spring Security 里的标配不要自己拼接 MD5。登录成功后生成一个 UUID 作为 token存到 Redis 里并设置 7 天过期前端每次请求带上 token后端拦截器解析。这种方案在前后端分离和移动端适配上都比 Session 简单。注册逻辑可以参考这段简化代码// 用户注册简化 public UserVO register(RegisterDTO dto) { if (userMapper.selectByUsername(dto.getUsername()) ! null) { throw new BizException(用户名已存在); } User user new User(); user.setUsername(dto.getUsername()); user.setPassword(new BCryptPasswordEncoder().encode(dto.getPassword())); user.setNickname(dto.getNickname()); user.setCreditScore(100); userMapper.insert(user); return toVO(user); }很多 Java 初学者容易把“面向对象编程”理解成写一堆类结果一个用户类里塞了几十个字段。我建议把用户信息拆成两部分账号安全信息登录名、密码、手机号和主页展示信息昵称、头像、简介。查询用户列表时单独映射 VO避免把密码字段带出去。权限方面用户角色用一个 int 字段区分0 普通用户、1 车主、2 管理员管理员接口通过拦截器校验角色不引入 Spring Security避免配置复杂。3.2 单车发布与预约乐观锁更新防止车辆被“抢单”车辆发布接口本身不难车主填车辆标题、描述、位置、租金后台保存后状态设为空闲。真正的难点在预约。车辆预约本质上是一次库存扣减两个用户同时预约同一辆空闲车如果不加控制数据库会出现脏数据。我先说最简单的做法在车辆表加一个 version 字段更新时带上 version 旧值更新后检查影响行数如果为 0 就说明车辆被抢了直接返回“车辆已被预约”。对应 MyBatis 的更新语句大致是这样update idupdateVehicleStatus UPDATE vehicle SET status #{newStatus}, version version 1, update_time NOW() WHERE id #{vehicleId} AND status #{oldStatus} AND version #{version} /update这种写法把判断和更新合并到一条 SQL天然原子不需要额外加锁。但前提是车辆所有状态变更都必须带上旧状态条件否则状态机就形同虚设。我把这个 update 方法设计成所有状态流转的唯一入口其他 Service 一律依赖它不直接写 update 语句。Service 层再配合事务Transactional(rollbackFor Exception.class) public OrderVO createOrder(Long userId, Long vehicleId) { Vehicle v vehicleMapper.selectById(vehicleId); if (v null || v.getStatus() ! VehicleStatus.IDLE) { throw new BizException(车辆不可预约); } int rows vehicleMapper.updateVehicleStatus(vehicleId, VehicleStatus.IDLE, VehicleStatus.RESERVED, v.getVersion()); if (rows 0) { throw new BizException(手慢了车辆已被预约); } Order order buildOrder(userId, vehicleId); orderMapper.insert(order); return toVO(order); }这里要特别说明插入订单和更新车辆必须在一个事务里但乐观锁的更新语句要先执行因为它返回影响行数如果失败直接抛异常事务回滚就不会产生订单。事务边界用 Transactional 控制方法执行前开启事务方法执行后提交异常则回滚。取消预约也要处理。用户取消或者超时未取车需要把车辆状态从已预约改回空闲同时把订单状态改为已取消。我写了一个定时任务每分钟扫描状态为待取车且 start_time 超过 15 分钟的订单调用同一个状态更新方法。定时任务和用户取消同时触发时乐观锁一样能防住重复更新这一点很踏实。3.3 骑行轨迹记录地图API Key保护和批量坐标上报骑行轨迹是平台的特色功能。我选择通过前端 H5 的 Geolocation 获取经纬度每 5 秒上报一次坐标后端保存到轨迹表。行程结束后调用高德 API 逆地理编码生成距离和用时统计再渲染成路线图。距离计算如果不想每次都调接口可以用 Haversine 公式在后端直接算// 计算两个经纬度之间的距离单位米 public static double distance(double lat1, double lng1, double lat2, double lng2) { double radLat1 Math.toRadians(lat1); double radLat2 Math.toRadians(lat2); double a radLat1 - radLat2; double b Math.toRadians(lng1) - Math.toRadians(lng2); double s 2 * Math.asin(Math.sqrt( Math.pow(Math.sin(a / 2), 2) Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2) )); return s * 6371000; }批量坐标上报接口我设计为一次接收最多 50 个点而不是一个点一个请求大大降低了网络开销。轨迹保存时先插入 ride_track 主记录拿到 id再循环插入 track_point数据库里用 point_no 排序保证轨迹回放顺序。地图 API 的调用必须在后端代理。前端只调用自己的接口高德 Key 放在服务器环境变量里。逆地理编码有并发限制每隔一段时间调用一次即可不要对每个轨迹点都做逆地理编码。路线分享到社区时我生成一张静态地图图片 URL前端直接展示省去加载大量实时点列表页响应速度快了很多。3.4 骑友社区发帖、点赞、评论与 WebSocket 通知社区模块重点不在发帖而在内容的实时性和热度排序。发帖接口支持文字和最多 9 张图片图片上传要用独立存储路径或 OSS。我早期把图片存在应用目录里后来发现磁盘占用很大打包也不方便改成了云对象存储服务器只存 URL。帖子和评论的分页查询我用 MySQL 的 LIMIT 加 Redis 缓存最近热门帖。热门帖排序权重我设置为浏览量×0.3 点赞数×0.5 评论数×0.2再乘以一个时间衰减因子这样老帖子不会一直霸榜新活动又能快速冒头。这个公式不用多精确关键是让内容保持活力。帖子有新评论或点赞时我用 WebSocket 向前端推送消息。连接建立后Session 与用户绑定放在 ConcurrentHashMap 里Component public class WebSocketServer { private static final MapLong, WebSocketSession SESSIONS new ConcurrentHashMap(); }推送失败时回写站内消息表等用户下次登录再拉取。如果同一用户多端登录只保留最新 Session避免推送到已经关闭的连接。这个方案能撑住几百个在线用户再大的量就需要引入消息中间件了。4. 并发、一致性与性能优化Java项目里的硬骨头4.1 数据一致性为什么不能用“先查再改”的普通写法网上经常有人问“java怎么保证数据一致性”。在这个项目里最直观的回答就是先用数据库约束兜底再用应用层缓存提速。数据库兜底的核心是状态条件和版本号也就是前面说的乐观锁。我开发时先写过一版看起来很合理的普通代码Vehicle v vehicleMapper.selectById(vehicleId); if (v.getStatus() 0) { vehicleMapper.updateStatus(vehicleId, 1); orderMapper.insert(order); }这段代码单线程测试完全没问题用 JMeter 模拟 50 并发马上就会出现多个人都看到 status 0然后都执行 update直接插出多条订单。因为 select 和 update 不是原子的中间隔了网络和代码执行时间。面试时谈数据一致性可以从这段反例讲起然后引出乐观锁和事务逻辑会非常顺。如果你担心乐观锁在并发特别高时导致很多重试也可以用悲观锁方案在事务里使用 SELECT ... FOR UPDATE 锁住车辆行然后再执行更新。这种方式能严格串行化但要注意 FOR UPDATE 查询必须走主键索引否则会锁全表事务里的操作要短平快不要让锁长时间占用。我实际测试了 200 个用户同时预约同一辆车。乐观锁方案最终只有 1 个用户成功其他返回失败或进入重试逻辑数据库没有出现一单多车或一车多单的情况。失败请求由前端引导用户查看其他车辆虽然体验有点生硬但数据是对的。4.2 Redis 分布式锁为什么释放锁必须用 Lua 脚本有些场景比如积分奖励、取消预约退还信用分会涉及多个 Redis 和数据库操作单纯靠数据库行锁就不太合适。这时我引入 Redis 分布式锁用 SET NX EX 命令实现// Redis分布式锁工具方法简化版 String lockKey lock:bike: vehicleId; String requestId UUID.randomUUID().toString(); Boolean locked stringRedisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 业务操作 } finally { // Lua脚本释放锁确保只删除自己的锁 stringRedisTemplate.execute(releaseLockScript, Arrays.asList(lockKey), requestId); } }这里最容易被忽视的是“锁过期时间”和“释放锁时必须校验归属”。网上很多简化代码直接删除 lock key极可能导致把别人刚获取的锁删了。我踩过这个坑后来把释放锁改成 Lua 脚本先比较 value 再删除才算真正安全。脚本内容如下-- 释放锁的Lua脚本 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end乐观锁和分布式锁怎么选我的体会是单行数据状态变更优先用乐观锁因为无额外组件、性能好跨服务、多行数据、需要原子完成多个动作时再用分布式锁。不要动不动就加锁很多所谓的并发问题其实就是 SQL 条件和事务边界没设计好。锁过期时间也要考虑。如果业务操作超过 10 秒锁被自动释放后续请求就可能进入临界区。一个简单做法是给锁加“续期”线程每 3 秒检查业务是否还在执行如果是就把过期时间再延长。个人项目里我通常把过期时间放宽到 30 秒并保证锁内操作控制在毫秒级这样省去续期复杂度。4.3 点赞与热度排序Redis 扛高频写MySQL 做最终落库点赞数这种高频写指标如果每次请求都直接 UPDATE 帖子表的 like_count数据库压力会很大。我在 Redis 里用 Hash 存储每个帖子的点赞数和点赞用户集合用户点赞时只操作 Redis通过定时任务每 5 分钟把增量同步到 MySQL。这样即使 Redis 宕机最多丢几分钟的点赞数据社区场景可以接受。点赞要保证幂等。用户点赞前先判断是否已经在点赞用户集合里Redis 的 Set 操作天然支持去重。如果用户取消点赞从集合里移除同时点赞数减一。定时任务同步时我采用一种很稳的方式先读取 MySQL 旧值再叠加 Redis 中的增量最后覆盖更新。因为这个业务对实时性要求不高不需要用消息队列做到强一致。热度排序我在发布和点赞、评论事件发生时直接更新帖子表里的 hot_score 字段列表查询只需要 ORDER BY hot_score DESC。有朋友建议用 Elasticsearch我说这个数据量阶段没必要等帖子超过百万再考虑搜索引擎。把架构复杂度提前堆上只会给自己添乱。4.4 一个容易被忽略的优化操作日志用 Spring AOP 记录我给用户操作日志写了一个自定义注解 OpLog通过 Spring AOP 在方法执行前后切面记录入参和结果。代码大概长这样Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface OpLog { String value() default ; }Service 方法上标注 OpLog(预约车辆)切面在方法执行前记录开始时间执行后把方法名、参数、耗时、结果写入 operation_log 表异常时记录异常堆栈。这种实现背后就是 Java 动态代理Spring AOP 默认对接口使用 JDK 动态代理对类使用 CGLIB 代理。面试时问到“java动态代理”你就可以说在项目里用 AOP 做过日志切面这比单纯背诵概念更有说服力。这里有一个需要注意的坑Spring AOP 只对被 Spring 容器管理的 Bean 生效同类内部方法调用不会走代理。如果你在同一个类里调用带 OpLog 的方法日志是记录不到的。需要让操作发起方从外部调用或者拆到另一个 Service 类里。5. 环境配置、部署与常见问题排查把坑提前填平5.1 Java环境变量与JDK版本先消除“源发行版17”报错很多人卡在第一步Java 环境变量配置。我建议直接装 JDK 8 或 JDK 17然后设置 JAVA_HOME、PATH 和 CLASS_PATH。Windows 下重点确认 PATH 里没有多个 JDK 路径否则命令行执行 java -version 经常是旧版本。有个高频报错是“java: 警告: 源发行版 17 需要目标发行版 17”这是 IDE 和 Maven 用了不同 JDK 版本导致的。解决办法是统一检查三处Project SDK、Language Level、Maven 的 JAVA_HOME。使用命令检查java -version javac -version echo $JAVA_HOME mvn -v让这几条命令输出完全一致就成功了大半。如果你用 Spring Initializr 生成项目它默认可能选了 Java 17但本机是 8就会一直编译报错。可以在 pom.xml 里显式指定 maven.compiler.source 和 target或者直接用 spring-boot-starter-parent 自带的属性覆盖。5.2 打包部署外部配置文件剥离与启动命令项目开发完我习惯用 Maven 打成可执行 Jar 放在服务器上跑。有一个关键操作把数据库地址、Redis 密码、高德 Key 这些变量放到 application.yml 外部启动时用 --spring.config.additional-location 指定外部配置文件。这样换服务器、换环境不用重新打包也避免密钥被传进 Git 仓库。部署命令可以写成一行nohup java -jar bike-platform.jar --spring.config.additional-location/home/app/conf/application-prod.yml /home/app/logs/app.log 21 后台日志重点看两类错误SQL 异常和 Redis 连接超时。SQL 异常大多是数据库表和实体映射字段不一致Redis 超时多半是未配置密码或安全组没放开 6379 端口。用 nohup 跑起来后记得用 jps 或 ps -ef 确认进程存在。5.3 常见问题速查表我一年内实际踩过的坑我把开发中遇到过的问题整理成一张表方便你直接对照排查。现象可能原因解决方法中文乱码数据库连接未指定 UTF-8JDBC URL 加 useUnicodetruecharacterEncodingUTF-8接口报 401token 过期或未传 Header检查 Redis key 是否存在前端请求头是否带 Authorization预约失败但订单插入事务未生效确认 Transactional 是否加在 public 方法上Redis 锁死锁释放锁时没有校验 value使用 Lua 脚本先比较后删除地图不显示Key 被前端暴露或未配置正确Key 放后端环境变量域名白名单核对端口被占用上次进程未退出lsof -i:8080 或 netstat -ano 找到 PID 再 kill定时任务重复执行多实例部署单机部署或者用 Redis 分布式锁锁住任务列表查询慢缺少索引或全表扫描用 EXPLAIN 分析 SQL补齐 status、hot_score 索引还有一个我印象很深的坑用户批量导入时在循环里用 list.contains 判断重复数据量大以后非常慢后来改成 HashSet性能立竿见影。这就是“java容器”选择的重要性。另一个坑是时间格式化我用字符串转 Date 比较部署到服务器后差了 8 小时。后来统一用 Instant 和 Duration 处理时间差再也没出过问题。基础概念背得再熟也要在真实数据环境下踩一遍才算真的会。结尾给准备动手的同学的几点实在建议做这个项目的过程中我最大的体会是技术选型、框架版本、中间件这些外面文章天天讨论的东西并没有想象中那么重要真正决定项目成败的是数据库表设计和状态流转是否清晰。拿预约功能来说如果开始就没想清楚车辆状态有哪些、转变条件是什么后面不管用什么锁、什么框架都会漏洞百出。最后再分享一个小技巧写完核心模块后可以自己构造一个并发测试用 JMeter 或干脆写几十行 Java 线程代码去模拟多人同时操作。我就是在测试中发现了乐观锁的更新行数判断问题也验证了 Redis 锁必须用 Lua 释放。这种自动化验证方法比自己满屏点按钮靠谱得多。如果你也在做类似平台建议先把“车辆状态机”画清楚再动工写代码。这样后面加功能、改需求你会比别人省很多时间。
返回列表