ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SpringBoot+Vue物品租赁系统开发实战:数据库建模与并发控制

SpringBoot+Vue物品租赁系统开发实战:数据库建模与并发控制 前阵子刚折腾完一套基于SpringBootVue的物品租赁系统管理系统从需求分析到数据库建模再到前后端联调、打包部署整个流程走下来最大的感触是租赁系统表面看就是个“带日期的商城”真拆开做才发现比商城麻烦不少。物品在某个时间段内只能属于一个订单订单要经历上架、下架、押金、租用中、归还、结算一大堆状态每一环都要有校验。做毕业设计、求职简历项目或者公司内部的小租借工具这套JavaMySQLMyBatisVue的组合都很合适。这篇把完整设计思路和实操细节写出来包括数据库怎么建模、状态机怎么设计、并发下单怎么防超租、MyBatis分页和缓存怎么用以及部署时最容易踩的坑给后面要做类似系统的人一个能直接对照的参考。1. 物品租赁与电商的本质差异订单生命周期和时间冲突1.1 租赁订单的生命周期比买卖订单多出不止一步传统电商订单通常就是下单、支付、发货、收货、完成顶多加一个退款。但租赁订单不行它多了一个“归还”环节而且押金的收退和租金结算也完全不是一回事。我第一版设计时想得很简单把租赁订单当成“带租期字段的普通订单”结果业务状态根本表达不清楚。比如物品已经寄给租客了这笔订单是“已完成”吗不行租期还没到物品还在别人手里得有一个明确的“租用中”状态。租客申请归还之后还要有核对环节——物品有没有损坏、有没有超期、押金扣多少这些都发生在订单接近尾声的时候。最后我把订单的生命周期定成这样待支付押金 → 待发货 → 租用中 → 待确认归还 → 已完成中间还有已取消、已关闭这些异常态逾期这种特殊情况放在后文细说。这个状态机是整个系统的骨架后面所有的接口、页面按钮、权限判断全部围绕它展开。1.2 时间冲突是租赁系统的第一约束租赁和买卖最本质的区别在于买卖商品可以卖完就没了库存总量是静态的租赁物品则强调“某个时间段是否可用”。同一台相机今天被A租了那今天就不能再租给B哪怕B只租两个小时也不行只要时间段有重叠就冲突。这意味着每次提交租赁订单前系统必须判断物品在目标租期内有没有其他有效订单占用。光靠Java代码里查列表再循环判断不够安全必须把这种校验下推到数据库层面用带时间条件的SQL去查重叠记录。这块SQL的写法其实不复杂关键是索引和状态条件要设计对后面章节会专门讲。1.3 为什么是SpringBootVueMySQLMyBatis这套组合做这套系统前我也纠结过其他方案但综合下来这套组合在学习和实际开发之间平衡得最好。SpringBoot解决了配置地狱的问题内嵌Tomcat、自动装配一个main方法就能起服务非常适合快速搭后台。MyBatis看起来比MyBatis-Plus麻烦但胜在SQL完全可控租赁系统里有大量自定义查询比如时间重叠判断、多条件筛选、分组统计自己写SQL反而心里有底。MySQL在这种单机业务量下性能完全够用运维也简单。前端选Vue是因为组件化和开发效率都高用户端和管理端界面可以抽出大量公共组件复用。如果只为了快速交差用MyBatis-Plus也行但我建议学习项目还是手写SQL把原理吃透。这套技术栈招人面也广写进简历里认可度高。2. 数据库建模把“可租状态”落到表和SQL索引上2.1 四张核心表与一张流水表我先明确一点租赁系统不建议把押金记录和订单记录混在一张表里。押金有收有退还可能存在扣除赔偿、部分退还的情况单独一张流水表记录起来清晰得多对账也方便。核心表我设计成下面几张表名作用关键字段user用户表租客和物品所有者共用id、username、password、nickname、phone、balance、statusitem物品表记录可租赁的货品id、user_id、name、description、category、daily_price、deposit、cover_img、statusrental_order租赁订单表业务核心id、order_no、item_id、user_id、rent_start、rent_end、daily_price、deposit_amount、total_amount、status、create_timepay_log押金与租金流水表id、order_id、type、amount、status、create_time物品表里的user_id表示物品的上架人可以是C端用户上传的物品也可以由管理员统一录入。status字段管理物品自身状态0待上架、1已上架可租、2已出租、3下架。订单表则必须冗余一份daily_price和deposit_amount不能直接去join物品表拿当前价格。原因是物品价格可能在中途调整但历史订单的租金和押金应当按下单时快照来结算否则会出现租客下单时200一天到期时价格改成300结算金额跟着飘的严重问题。2.2 订单状态机的取值约定订单表里我用的status是int类型取值约定如下值含义说明0待支付押金下单成功但尚未支付超时未付自动关闭1待发货押金已付等待物品所有者或管理员发货2租用中租客确认收货租期开始计算3待确认归还租客申请归还等待确认物品状态4已完成归还确认、押金结算完毕5已取消租客或系统取消的无效订单为什么用int不用字符串一是存储占用小、索引效率高二是避免手写字符串状态时大小写拼写出错。页面展示状态名时由前端定义映射关系后端返回数字即可。物品表和订单表是联动的物品状态为1可租才能下单下单支付押金后物品状态要改成2已出租订单回到已完成或已取消物品状态再恢复成1。这个联动逻辑必须放在Service层事务里前面加状态预检后面更新状态顺序不能乱。2.3 时间冲突查询与索引设计判断时间重叠的SQL是我认为整个系统里最值得抠的部分。逻辑上两个租期冲突的条件是新租期开始时间小于已有订单的结束时间并且新租期结束时间大于已有订单的开始时间。也就是“新开始 旧结束 且 新结束 旧开始”。对应查询大概是这样的SELECT id FROM rental_order WHERE item_id #{itemId} AND status IN (1, 2, 3) AND rent_start #{newEnd} AND rent_end #{newStart} LIMIT 1注意status IN只包含那些仍然占用物品时间段的订单待发货、租用中、待确认归还。已完成和已取消都不占用必须排除。索引我建的是(id, status, rent_start, rent_end)联合索引查询时能通过item_id快速锁定物品再用status过滤状态最后在时间范围内筛选。如果表数据量大了这个查询也可以改成用租期结束时间做倒序先查最近要归还的订单。一个小坑如果订单要修改租期比如租客申请续租那么做冲突校验时要记得排除当前订单自己否则会出现“修改后和自己的原租期冲突”的假阳性。我之前就漏掉过这一步测试续租功能时排查了半天。2.4 金额类型与默认值细节金额字段统一用DECIMAL(10,2)不要用FLOAT或DOUBLE。金额计算要求精确浮点数会有精度误差这在租金结算、押金退还时是不能接受的。建表时几个容易被忽略的默认值设置状态字段一般要DEFAULT 0create_time直接DEFAULT CURRENT_TIMESTAMPupdate_time建议设置成DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP省去每次手动更新时间的代码。MySQL用户创建表的时候要注意把字符集设置为utf8mb4排序规则用utf8mb4_general_ci否则用户昵称里存个特殊字符或者表情插入时直接报错。这个坑几乎每个人都会踩一次。3. 后端实现SpringBootMyBatis的交易链路与并发控制3.1 分层结构与核心接口设计后端我按标准的Controller-Service-Mapper三层来组织entity对应表结构dto接收前端参数vo返回给前端展示。结构大概这样com.example.rental ├── controller // 接口层只做参数接收和响应封装 ├── service // 业务层状态机和事务都在这层 ├── mapper // MyBatis数据访问层 ├── entity // 数据库实体 ├── dto // 请求参数对象 └── vo // 视图返回对象核心接口大致分三类物品管理发布、上下架、分页查询、详情、下单流程提交订单、支付押金、发货、确认收货、申请归还、确认归还、取消订单、个人中心我的订单列表、我的发布、流水记录。以提交租赁订单为例Controller只接收itemId、rentStart、rentEnd这些参数组装成dto后交给Service。Service内部要做的事很多判断物品是否存在且上架、校验租期合法性、查时间冲突、计算金额、创建订单、修改物品状态。这一串操作必须在一个事务里任何一个环节失败都要全部回滚。3.2 下单支付押金的事务边界我把“提交订单”和“支付押金”拆成两个接口而不是一个接口同时完成原因是现实中允许用户先下单、稍后再付款而且超时未支付要自动关单。但从事务设计角度看创建订单时必须同时锁定物品资源否则会出现A创建了订单还没付款B也创建了订单两个订单指向同一个物品的同一段租期。我的做法是创建订单这一步就把物品状态从“1可租”改成“2已出租”同时生成状态为“0待支付押金”的订单。如果用户最终不付款导致订单超时关闭再把物品状态改回去。这样设计的好处是资源占用的时间点非常明确并发环境下不容易超租。伪代码如下Transactional(rollbackFor Exception.class) public Long createOrder(CreateOrderDTO dto) { Item item itemMapper.selectForUpdate(dto.getItemId()); if (item null || item.getStatus() ! 1) { throw new BizException(物品不可租); } // 时间冲突校验 int conflict orderMapper.countConflictOrder(dto.getItemId(), dto.getRentStart(), dto.getRentEnd()); if (conflict 0) { throw new BizException(该时段已被预订); } // 创建订单状态为待支付押金 RentalOrder order buildOrder(item, dto); orderMapper.insert(order); // 锁定物品 itemMapper.updateStatus(item.getId(), 1, 2); return order.getId(); }selectForUpdate是悲观锁直接锁住物品行事务提交后释放。这种方案在物品数量不大、并发不高的场景下完全够用也能保证判断状态到修改状态之间不会插入其他事务。3.3 用行锁和状态条件解决“同时下单”问题上面代码里我用了SELECT ... FOR UPDATE这是悲观锁方案。后来我优化过一版改成纯乐观的原子更新减少锁等待的时间。核心思路是利用SQL中的条件更新UPDATE item SET status 2 WHERE id #{itemId} AND status 1执行这条UPDATE后如果影响行数是1说明抢锁成功可以继续创建订单影响行数是0说明物品已经被别人租走了直接返回“物品已下架或者已被预订”。这种方式的妙处在于MySQL的UPDATE本身就是行级锁加原子操作判断和修改一步完成不需要先SELECT再UPDATE也省去了显式加锁带来的死锁风险。在实际项目中我推荐优先用这个方案。如果还要更稳妥可以对(item_id, rent_start, rent_end)设计防重约束双保险。3.4 MyBatis缓存、分页与动态SQL很多教程上来就开MyBatis二级缓存但在租赁系统这种状态频繁变化的场景里我劝你谨慎。一级缓存是SqlSession级别的一次请求默认开启基本不会有问题二级缓存是namespace级别的如果开启了订单状态更新后缓存里的旧状态可能还没失效导致查询到过期的物品状态。尤其是物品表status字段变化极多开二级缓存等于给自己埋雷。分页直接用PageHelper插件用法很简单PageHelper.startPage(pageNum, pageSize); ListItemVO list itemMapper.queryPage(condition); PageInfoItemVO pageInfo new PageInfo(list);注意PageHelper.startPage后面要紧接着执行Mapper查询中间不能穿插其他查询语句否则分页会作用到错误的SQL上。这是官方文档写了但很多人不看的地方。动态SQL主要用在物品列表的多条件筛选上按分类、按价格区间、按名称模糊查询都是可选条件用 标签拼装即可。这里有一个被问过很多次的坑在XML里写时间比较的小于号比如rent_start #{newEnd}直接写号会报XML解析错误要写成或者整个包进 里。3.5 数据一致性的兜底状态机校验与幂等Java后端保证数据一致性不能只靠数据库事务业务层也要做状态机校验。我的经验是每个状态流转接口进入Service时先根据orderId查出当前订单然后判断当前状态是否等于“允许进入下一步的前置状态”。比如确认发货接口前置状态必须是1待发货如果订单已经是2租用中理论上这个接口就不该被调用成功。if (order.getStatus() ! RentalOrderStatus.WAIT_SEND) { throw new BizException(当前订单状态不允许发货); }这套校验能挡住绝大多数重复请求和乱序请求。押金退还也要做幂等退款接口先查pay_log里这笔订单是否已经存在成功的退款流水存在就直接返回成功或者用数据库唯一约束挡住重复退款记录否则网络重试会导致租客收到两次退款。Transactional还有一个隐蔽坑同一个类内部方法调用比如Service里面A方法调用同类B方法B上的Transactional是不生效的因为Spring事务是基于代理实现的。要拆到不同Bean里或者自己注入自己再调用否则你以为有事务实际每一条SQL都是自动提交出了异常数据对不上账。4. 前端Vue页面组织路由、组件与订单状态的联动4.1 目录结构与页面清单前端用Vue 2加Element UI虽然Vue 3已经普及但这套组合资料多、坑少做这类管理系统足够稳。目录结构大致如下src ├── api // axios请求封装 ├── assets ├── components // 公共组件 ├── router // 路由配置 ├── store // Vuex状态管理 ├── views │ ├── admin // 管理端页面 │ └── user // 用户端页面 └── App.vue用户端页面包括首页物品列表、物品详情、下单页、我的订单、订单详情、个人中心管理端包括物品管理、订单管理、用户管理、数据概览。两类端共用一套后端接口只是页面结构和权限不同。组件拆分的思路是物品卡片ItemCard、订单状态标签OrderStatusTag、日期范围选择器RentDatePicker这些高频出现的区块单独抽成组件详情页和列表页都能复用。4.2 路由参数与动态路由的复用陷阱物品详情页和订单详情页都属于动态路由路径像/item/:id和/order/:id。接收参数时用this.$route.params.id获取这本身没什么难度真正容易踩坑的是组件复用问题。Vue Router在切换同一个路由时默认会复用组件实例。什么意思你在物品列表页点A物品进详情页返回列表再点B物品详情页组件不会重新创建created钩子不会再次触发页面显示的很可能还是A物品的数据只是路由参数变成了B的id。解决办法有两种一种是在组件里watch $route变化参数变了重新拉数据另一种更简单在router-view上加一个keyrouter-view :key$route.fullPath/router-view强制路由变化时重建组件。我个人倾向于第二种代码改动少理解和维护都容易。路由守卫也要注意我的订单页面需要登录才能访问在router.beforeEach里判断Vuex中是否存在token没有就跳登录页。4.3 日期选择与租金试算下单页是整个前端的核心交互用户要选租期看到租金和押金的实时试算。Element UI的日期选择器要配置两点一是禁选过去的日期二是结束日期必须晚于开始日期。el-date-picker v-modelrentRange typedatetimerange :picker-optionspickerOptions changecalcAmount /el-date-pickerpickerOptions里面的disabledDate函数把今天之前的日期禁用掉。租金试算就是拿两个时间戳的差值除以一天的毫秒数向上取整得到天数再乘日租金加押金。这里要提一个前后端很容易不一致的细节后端按自然日计算租期还是按24小时计算租期必须提前商量好。如果后端按自然日前端试算也要按自然日如果两端算法不一致用户看到的试算金额和最终扣款金额对不上就等着挨投诉吧。前端校验不能只限日期还要在提交前判断所选日期类型能不能覆盖后端要求的时间精度。比如后端字段是datetime前端传的是date那“当天租当天还”可能因为少了时间部分被后端判定为非法时间段。4.4 axios封装与登录态处理axios请求我统一封了一层主要做两件事请求拦截器里把token塞进header响应拦截器里处理统一错误码。service.interceptors.request.use(config { config.headers[token] getToken(); return config; }); service.interceptors.response.use(response { if (response.data.code 401) { router.push(/login); return Promise.reject(未登录); } return response.data; });401统一跳登录前端就不用每个请求单独处理登录失效了。订单状态标签我封装成OrderStatusTag组件传入订单状态的数字组件内部根据映射关系渲染对应的颜色和文字这样订单列表和订单详情都能用同一套展示规则避免不同页面写出的状态文案不一致。两个页面对同样状态的文案都有过对不上的问题后来统一收敛到一个filter文件里所有页面从这个文件取值彻底治好强迫症。5. 从本机跑通到打包部署实操记录与避坑复盘5.1 本地环境准备与数据库导入整套系统的本地运行环境JDK 8、Maven 3.6、MySQL 8.0、Node 14。MySQL安装时最容易出的问题是安装完成后命令行连不上多半是服务没启动或者root密码策略太复杂。建议安装时选择“使用传统加密方式”而不是默认的强密码插件否则老版本的JDBC驱动连库会报错。数据库初始化我准备了一个db.sql脚本里面包含建库、建表和基础测试数据。导入直接用Navicat右键运行SQL文件就行。建库时要注意两点字符集选utf8mb4排序规则选utf8mb4_general_ci我已经在前面强调过这里再说一次因为太常见了。5.2 后端配置与前端代理后端application.yml里最关键的是数据源配置spring: datasource: url: jdbc:mysql://localhost:3306/rental?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.DriverserverTimezone必须指定否则MySQL 8下很容易报时区错误。MyBatis侧开启驼峰映射mybatis: configuration: map-underscore-to-camel-case: true不然数据库字段create_time映射到实体createTime直接是null排查起来还以为是数据没插进去。前端的跨域问题分开发环境两个思路。开发环境直接用Vue CLI的proxy代理vue.config.js里把/api开头的请求转发到后端浏览器无感知也不用后端配置CORSmodule.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } };生产环境用Nginx转发同样只暴露前端域名所有/api请求都反代到后端服务。5.3 打包构建与Nginx部署后端打包很简单mvn clean package -DskipTests java -jar target/rental-0.0.1-SNAPSHOT.jar前端构建npm run build构建产物是dist目录把它丢到Nginx的html目录再配置一个server块。这里必须注意如果前端使用了history路由模式刷新页面会出现404Nginx要加try_files配置location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }try_files是很多Vue项目部署后刷新白屏的根源100个人里有90个栽在这里。5.4 实测中遇到的四个坑第一个坑是前端传时间少了几秒。租期结束时间选择的是当天23:59:59但前端date-picker只传日期不传时间到了后端变成当天00:00:00导致最后一天实际没被计算进去。方案是把时间值统一定义成字符串格式后端用LocalDateTime解析前先补齐时间部分。第二个坑是MyBatis返回Map时create_time字段变null。如果查询结果的接收对象是Map而不是实体类驼峰自动映射失效必须用别名或者resultMap显式指定。我后来把所有涉及时间的展示全部改用了实体VO接收属性映射有保证。第三个坑是同一物品重复上架后物品ID和时间冲突判断把已取消的订单也算进去了。加状态过滤前被取消的订单依然占用着时间段新订单永远创建不了。这个问题排查了挺久最后在SQL里补了status IN条件解决。第四个坑是端口占用。后端8080端口被占用导致启动失败直接换端口或者杀掉占用进程即可但要注意后端端口一变前端proxy和Nginx转发配置都要同步改别只改一处。这些坑没有一条是算法级别的难题全部是细节问题但任何一个都能让系统跑不起来或者账目对不上。做项目最花时间的其实不是写代码是排这些细节。最后分享一点个人体会做这类管理系统数据库设计和状态机约定才是重头戏页面代码反而是体力活。动手写接口前先把状态流转表研究透、把每张表每个字段默认值定好后面写代码会顺畅很多。如果后续想往商用方向扩展还可以加站内提醒、租凭日历、信用积分、消息通知这些模块核心的表结构不用大改扩展性是够的。
返回列表