SpringBoot大学生租房系统:从数据库建模到并发预约的高质量实践
发布时间:2026/9/8 1:07:16来源:尧图网络
做毕设或练手项目时“租房系统”是特别常见的选题但正因为常见才容易做得平庸。如果只是把CRUD堆一遍、页面套个模板就交差答辩时老师一问业务表结构为什么这么设计、并发预约怎么防超卖、图片文件怎么存立马就露馅了。我去年帮一个学弟完整梳理过基于SpringBoot的大学生租房系统从需求拆分、数据库建模到核心接口实现再到线上排查手记整个走了一遍。这篇就把其中最有价值的部分整理出来给正在做同类项目的同学一个可以直接参照的“更接近真实工程”的版本。1. 大学生租房这个场景和普通租房系统差在哪做系统之前先把业务想透。很多人拿到“大学生租房系统”这个题目上来就画用户表、房源表、订单表最后做出来一个“简版链家”。这不算错但丢掉了这个题目的核心区分度面向大学生的租房业务流程和痛点跟社会租房不一样。1.1 需求特殊性短周期、强波峰、弱信用大学生租房有几个显著特征租期短、换房频繁多数是半年或一年一租毕业季集中换房大四实习、考研备考还会出现短租需求。系统里的“合同管理”和“到期提醒”功能就要比普通租房系统做得更细。季节性极强每年6-7月毕业季、9月开学季是两个需求波峰。这意味着系统的搜索、预约、收藏功能在特定月份流量骤然上升后台统计报表也要能按“开学季/毕业季”维度的趋势分析。预算敏感、决策周期短学生群体对租金价格极度敏感通常会反复比价。所以“条件筛选”是核心功能不能只是简单的关键字搜索必须要支持按价格区间、距离学校距离、合租/整租、有无空调独卫等维度组合筛选。轻信用、重担保在校学生普遍没有流水、没有抵押物房东担心收不到租、租客担心押金被扣。系统里如果能加入“学生认证”绑定学号和“合同在线化”会让交易信任度明显提升这也是答辩时一个很好的业务亮点。1.2 角色与核心流程梳理这个系统我建议按三种角色来设计而不是常见的“用户管理员”两极模型学生用户找房、筛选、收藏、预约看房、在线签约、费用记录、退租申请。房东用户房源发布与上下架、预约处理、合同起草、账单管理、到期提醒、租客评价。平台管理员用户审核尤其是学生认证、房源审核、举报处理、数据统计、公告管理。核心业务闭环是学生搜索房源 → 收藏/预约看房 → 房东确认预约 → 双方线下看房 → 签约 → 生成账单 → 按期缴租 → 合同到期/退租 → 双方互评。整个流程中只要把“预约—签约—账单”这几个状态流转做好了系统的业务完整度就立住了。2. 技术选型为什么核心是SpringBoot配套如何选“基于SpringBoot”这个前提是合理的。在Java技术栈里SpringBoot确实是当前做这类业务系统综合成本最低的选择但选型不能只选一个主框架整个技术配套要一起考虑清楚。2.1 SpringBoot在这个项目里的取舍逻辑SpringBoot最大的价值不是“快”而是把Spring生态里那些配置复杂度吃掉了。比如原来Spring MVC要做一堆XML配置、要配数据源、要配事务管理器SpringBoot通过自动配置机制全部按约定处理掉了你只需要在application.yml里写关键参数。对租房系统这种典型的业务系统来说SpringBoot的成熟组件生态优势很明显需求点SpringBoot生态方案为什么这么选持久层MyBatis-Plus单表CRUD基本不用写SQL复杂的多表查询自己写XML兼顾效率与可控数据库MySQL 8.x关系型数据为主事务支持成熟InnoDB引擎稳定缓存Redis首页热门房源缓存、验证码存储、分布式锁实现预约防并发权限认证Sa-Token 或 Spring Security JWT接口鉴权三套角色用拦截器注解统一控制文件存储本地磁盘存储 Nginx静态映射开发简单部署时可平滑切换阿里云OSS/MinIO接口文档Knife4jSwagger增强版前后端联调方便答辩演示时直接看接口面板很加分2.2 关于Flowable的冷思考看到热搜词里有“springboot使用flowable”这里多说一句。Flowable是个很强的工作流引擎但大学生租房系统里基本用不上。因为租房业务的流程是相对固定且简短的预约—确认—签约—缴租—退租用状态机在代码里就能优雅地控制引入Flowable反而会把系统复杂度抬高一大截——维护流程定义、部署流程模型、处理待办任务表这些工作对于单体业务系统来说是负资产。如果你确实想在项目里展示“流程化”的概念我的建议是自研一个简单的状态机工具类把预约单的状态流转统一管理起来既能说明白业务逻辑又不用背上工作流引擎的重包袱。等将来业务流程真的变得多分支、多条件跳转了再评估Flowable也不迟。2.3 技术栈的版本搭配建议如果你现在是2026年动手做这个项目推荐直接用SpringBoot 3.2对应JDK 17。很多同学还在用2.7JDK8的老组合不是不能用而是新项目没必要守着旧版本。参考搭配SpringBoot 3.2.x JDK 17 MyBatis-Plus 3.5.7 MySQL 8.0.3x Redis 7.x Sa-Token 1.39.0 Knife4j 4.5.0 Maven 3.9.x提示SpringBoot 3.x基于Jakarta EE 9包名从javax.*换成了jakarta.*网上搜到旧教程的代码如果一直报“包不存在”基本就是这个原因。3. 数据库建模不是建几张表那么简单数据库设计是这类系统最见功底的部分。很多同学建表只图功能能用结果表之间有大量冗余字段、状态全靠字符串硬扛、金额用double存——全是要命的隐患。3.1 核心数据表结构设计我按“会员体系—房源体系—交易体系—运营体系”四条线来拆核心表一共12张会员相关user用户主表。字段包含id、username、password、phone、avatar、roleSTUDENT/LANDLORD/ADMIN、status、student_no学号、school_name、create_time等。user_auth用户认证记录表。学生认证和房东认证都走这张表包含user_id、auth_type、real_name、id_card脱敏存储、status待审核/通过/拒绝、audit_remark。房源相关house房源主表。字段包含id、landlord_id、title、description、rent_type整租/合租、price_month月租金decimal(10,2)、deposit押金、province/city/district/address、area_size、bedroom_count/hall_count/bathroom_count、decoration_level装修程度、is_available是否可租、status待审核/已上架/已下架/已出租、view_count、create_time。house_image房源图片表。一个房源多张图片包含house_id、img_url、sort_order。交易核心favorite收藏表。字段有user_id、house_id、create_time唯一索引(user_id, house_id)。appointment预约看房表。包含user_id、house_id、appointment_time、contact_name、contact_phone、status待确认/已确认/已取消/已完成、cancel_reason、create_time。contract租赁合同表。包含contract_no、user_id、landlord_id、house_id、start_date、end_date、monthly_rent、deposit_amount、pay_day每月缴租日、status生效中/已到期/已退租/已解约、signed_time、file_url。bill账单表。包含contract_id、bill_no、bill_type租金/押金/水费/电费/物业费、amount、due_date、status待支付/已支付/逾期、pay_time、pay_method。运营相关notice平台公告、10.complaint投诉举报、11.comment租后评价、12.operation_log操作日志。3.2 容易忽略但很关键的设计细节第一金额一律用DECIMAL(10,2)绝对不要用double/float。二进制浮点数在金额计算上会出精度问题经典的0.10.2不等于0.3租金、押金、违约金的计算尤其敏感。数据库用DECIMALJava实体用BigDecimal这个习惯从一开始就要养成。第二状态字段要跟状态机设计绑定。比如预约单的status我建议在代码里定义一个枚举类public enum AppointmentStatus { PENDING(0, 待确认), CONFIRMED(1, 已确认), CANCELLED(2, 已取消), COMPLETED(3, 已完成); private final int code; private final String desc; }数据库里存code数字接口返回时转换成描述文本。这样做好处是查询效率高、状态流转可控、后续加状态好扩展。第三所有业务表都要有create_time和update_time建议用MyBatis-Plus的TableField(fill FieldFill.INSERT)自动填充不要手动set。同时加上逻辑删除字段deleted这样数据不会真正物理删除查询时会自动带上deleted0条件安全可靠。第四合同编号、账单编号建议用业务规则生成不要用自增id直接当编号对外展示。比如HT20260601001含义是“合同-20260601-当日第1单”可读性强排查问题也方便。3.3 一个实用索引设计清单house表(city, district, status, price_month)联合索引支撑首页筛选查询landlord_id索引支撑“我的房源”列表。appointment表user_id和house_id各自建索引(status, appointment_time)联合索引支撑待办列表排序。bill表(contract_id, status)联合索引支撑账单查询与逾期筛选。注意索引不是越多越好索引过多会拖慢写入性能。核心原则是先分析业务查询路径再给最频繁的查询条件建索引。4. 核心业务功能实现细节选几个真正体现技术含金量的功能点展开说。这几个功能不能只是“能跑”还要说清楚设计思路是答辩时最能打的点。4.1 多条件组合搜索不许用LIKE塞满一切学生找房最常见的操作就是城市北京、区域海淀、价格1000-3000、户型两室、朝向南、配套有空调。这种组合筛选查询如果直接在Mapper里写一堆if动态SQL会非常繁琐也不安全。我建议在Controller层用一个查询参数对象接收在Service层用LambdaQueryWrapper动态拼接简单清晰public IPageHouseVO searchHouses(HouseQueryDTO query, PageHouse page) { LambdaQueryWrapperHouse wrapper new LambdaQueryWrapper(); // 价格区间 if (query.getMinPrice() ! null query.getMaxPrice() ! null) { wrapper.between(House::getPriceMonth, query.getMinPrice(), query.getMaxPrice()); } // 区域筛选 if (StringUtils.hasText(query.getDistrict())) { wrapper.eq(House::getDistrict, query.getDistrict()); } // 户型筛选 if (query.getBedroomCount() ! null) { wrapper.eq(House::getBedroomCount, query.getBedroomCount()); } // 只展示已上架且未被租出的房源 wrapper.eq(House::getStatus, HouseStatus.ON_SHELF.getCode()) .eq(House::getIsAvailable, true) .orderByDesc(House::getViewCount); return houseMapper.selectPage(page, wrapper); }这段代码的好处一眼能看出来空条件不拼接、查询安全参数走预编译、排序逻辑集中可控。这里要注意一个坑分页参数PageHouse必须放在Mapper方法的参数第一位否则MyBatis-Plus的分页插件不生效。很多人查出来发现total一直是0就是这个原因。4.2 预约看房的防并发处理预约这个动作天然存在并发风险同一个房源、同一个空闲时间段多个学生同时发起预约如果处理不当就会“超卖”——两个人都收到“预约成功”的提示。先说不推荐的方案先查询该时间段是否已被预约再插入预约记录。这种方式在并发场景下就是经典的时间差竞态——两个请求同时通过查询觉得自己没有冲突然后同时插入结果就冲突了。正确做法是在数据库层面兜底。首先为appointment表加一个唯一索引ALTER TABLE appointment ADD UNIQUE KEY uk_house_time (house_id, appointment_time, status_confirmed_flag);但这还不够因为唯一索引没法表达“同一房源同一天同一时间段只能有一单确认状态”这种业务语义。更可靠的设计是引入Redis分布式锁锁的key设计为lock:appointment:{houseId}:{appointmentTime}只有获取到锁的请求才能继续执行预约校验和插入public synchronized Boolean createAppointment(AppointmentCreateDTO dto) { String lockKey lock:appointment: dto.getHouseId() : dto.getAppointmentTime(); boolean locked redisLock.tryLock(lockKey, 3, 10, TimeUnit.SECONDS); if (!locked) { throw new BusinessException(当前预约人数较多请稍后重试); } try { // 二次校验该时段是否已被预约 long count appointmentMapper.selectCount( new LambdaQueryWrapperAppointment() .eq(Appointment::getHouseId, dto.getHouseId()) .eq(Appointment::getAppointmentTime, dto.getAppointmentTime()) .ne(Appointment::getStatus, AppointmentStatus.CANCELLED.getCode()) ); if (count 0) { throw new BusinessException(该时段已被预约请选择其他时间); } // 插入预约单 ... } finally { redisLock.unlock(lockKey); } }这段代码演示了“先抢锁再校验再写入”的套路。很多人写到这里就结束了但还有两个细节值得注意一是锁的超时时间要跟业务执行时间匹配设置太短会出现在锁自动过期后新的请求拿到锁、而前一个请求还没写完的尴尬设置太长又会影响吞吐。一般建议压测一下正常耗时再定。二是唯一索引仍然是必须的兜底防线。分布式锁只能保证单体应用内互斥如果将来拆成多实例部署只要有一个节点的锁实现有问题唯一索引就是最后的堡垒。两者配合才是稳妥的。4.3 合同到期自动检测定时任务不可少租房系统有一个“隐藏功能”很有用合同到期提醒和账单逾期提醒。毕业季房源周转快很多房东根本记不住每份合同什么时候到期系统如果能自动发送通知体验会提升一大截。实现方案用Scheduled注解做定时任务就够了不需要上XXL-Job。在配置类上开启EnableScheduling然后写一个到期检测任务Component public class ContractRemindTask { Scheduled(cron 0 0 8 * * ?) // 每天早上8点执行 public void remindExpiringContracts() { LocalDate today LocalDate.now(); LocalDate expiringDate today.plusDays(7); ListContract expiringList contractMapper.selectList( new LambdaQueryWrapperContract() .eq(Contract::getStatus, ContractStatus.EFFECTIVE.getCode()) .between(Contract::getEndDate, today, expiringDate) ); for (Contract contract : expiringList) { // 给房东和租客发送站内信/短信通知 notifyService.sendExpireRemind(contract); } } }这里有个经验之谈定时任务一定要做幂等控制否则任务双触发或者异常重推时用户会收到重复消息。最简单的办法是加一张task_record表记录每次任务的执行时间、处理数量、执行状态每次执行前先查一下是否已执行过。4.4 图片上传与访问路径设计房源图片是租房系统的门面。我做的方案是后端接收MultipartFile校验文件类型和后缀保存到服务器本地指定目录然后把文件访问的相对路径存到数据库前端拼接Nginx静态映射地址访问。核心代码如下public String uploadImage(MultipartFile file) { // 1. 校验文件大小限制5MB以内 if (file.getSize() 5 * 1024 * 1024) { throw new BusinessException(图片大小不能超过5MB); } // 2. 校验文件类型 String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); ListString allowedSuffix Arrays.asList(.jpg, .jpeg, .png, .webp); if (!allowedSuffix.contains(suffix.toLowerCase())) { throw new BusinessException(仅支持jpg/jpeg/png/webp格式图片); } // 3. 生成唯一文件名日期UUID防止重名覆盖 String newFileName LocalDate.now().toString().replace(-, ) UUID.randomUUID().toString().replace(-, ) suffix.toLowerCase(); // 4. 按日期分目录存储避免单目录文件过多 String datePath new SimpleDateFormat(yyyy/MM/dd).format(new Date()); File destDir new File(uploadRootPath datePath); if (!destDir.exists()) { destDir.mkdirs(); } // 5. 保存文件并返回相对路径 file.transferTo(new File(destDir.getAbsolutePath() / newFileName)); return /uploads/ datePath / newFileName; }文件存储这块开发环境和生产环境建议做配置切换。本地开发时存磁盘生产部署时如果服务器磁盘有限就切换到OSS/MinIO等对象存储。代码上抽象一个FileStorageService接口分别实现LocalFileStorageServiceImpl和OssFileStorageServiceImpl通过ConditionalOnProperty注解根据配置项激活不同实现这是很标准的做法。5. 从报错到修复一次预约状态异常的完整排查链路这部分是我个人最有感触的。项目本身做得差不多了联调时却遇到一个诡异的Bug用户明明取消了预约但后台统计“今日预约数”时这个单子还在。这个问题的排查过程比较典型写出来供参考。5.1 问题现象描述前端页面点“取消预约”按钮接口返回成功列表里也看不到这条记录了。但管理员后台的统计接口里status0待确认的预约数比预约表实际记录数多并且点进详情发现那条已取消的预约单操作日志时间是空的。5.2 排查步骤一先看接口层的参数与返回先在Controller取消预约的接口入口打了日志发现前端传的appointmentId是正常的接口本身没有报错但数据库里那条记录的status字段没有被更新。这就很奇怪了——返回成功但数据没变。5.3 排查步骤二看MyBatis-Plus的更新SQL执行情况在Service层加日志打印出update语句发现执行的是UPDATE appointment SET status 2, update_time ? WHERE id ?affected rows返回1从SQL层面看是更新成功的。但查数据库status居然还是0。到这里问题开始指向事务提交或MyBatis-Plus逻辑删除拦截的方向。5.4 排查步骤三定位到逻辑删除字段埋的雷查了一下appointment表的实体类发现注解是这样的TableLogic private Integer deleted;问题就出在这里。MyBatis-Plus的逻辑删除一旦启用所有的select都会自动追加WHERE deleted 0所有update在执行时也会自动带上AND deleted 0条件。表面上看没问题但我的Service代码里写了一个整表统计appointmentMapper.selectCount( new LambdaQueryWrapperAppointment() .eq(Appointment::getStatus, 0) );这个查询没问题会自动过滤已删除数据。真正的问题出在“取消预约”和“统计预约”走的是两个不同的Service方法而且取消预约接口里我用了一个自定义SQL更新语句Update(UPDATE appointment SET status #{status} WHERE id #{id}) int cancelAppointment(Param(id) Long id, Param(status) Integer status);这个自定义SQL没有拼接deleted 0条件而调用这个SQL时传入的appointmentId恰好来自一条之前测试时被逻辑删除的数据因为前端列表查询被逻辑删除过滤掉了但测试脚本里直接造了这么一条脏数据。于是这个update把逻辑删除的旧数据又“救活”改成了取消状态导致统计口径出现混乱。5.5 根因总结与修复根因有两层自定义SQL绕过了MyBatis-Plus的逻辑删除拦截更新了不该更新的数据系统里残留了逻辑删除的脏数据统计查询没有考虑deleted条件之外的业务边界。修复方案自定义SQL里显式加上AND deleted 0或者干脆不用自定义SQL直接用LambdaUpdateWrapper。增加数据订正脚本把历史脏数据物理清除或标记为无效。在统计接口里除了逻辑删除过滤再追加业务状态过滤比如只统计最近30天创建的预约单。这次排查的教训是引入ORM框架的增强功能如逻辑删除时一定要清楚它对全局SQL行为的影响尤其要警惕自定义SQL和框架拦截逻辑之间的缝隙。这类隐蔽问题没踩过坑的人很难一眼看出来。6. 关于安全细节和性能优化的建议很多毕业设计项目功能都齐全但在安全和性能维度缺乏思考导致答辩时被问住。这里针对租房系统列举几个最值得加的点。6.1 认证与授权我推荐用Sa-Token替代Spring Security。Sa-Token的学习曲线比Spring Security平滑得多而且对“登录认证 权限角色校验 踢人下线”这套需求支持得非常直接。核心逻辑就三步用户登录成功后StpUtil.login(userId)生成Token前端每次请求带Token后端通过拦截器校验StpUtil.checkLogin()对需要角色权限的接口加SaCheckRole(LANDLORD)或SaCheckRole(ADMIN)注解。密码存储必须用BCrypt加密不要用MD5。MD5已经被彩虹表打穿了BCrypt每次加密会混入随机盐即使相同密码加密结果也不同安全性高一个量级。6.2 参数校验与全局异常处理Controller层入参统一用ValidatedValid做参数校验配合RestControllerAdvice全局异常处理器。这样既保证非法参数不会进入Service层也让前端能拿到统一的错误响应结构code/message/data。我见过不少项目把参数校验散落在Service里用if-else堆代码乱得不行全局校验统一异常才是正解。6.3 性能优化策略缓存热门房源首页和推荐位的高频访问房源用Redis缓存房源摘要信息设置5-10分钟过期DB查询压力能降一大半。分页不要用深分页MyBatis-Plus的分页插件默认是limit offset, size当页码很大时比如第1000页offset会很大查询变慢。如果真的有这种场景用“上一页最后一条记录的id”来做游标分页。列表接口只查必要字段用VO对象而不是把整个实体扔给前端既减少网络传输也避免把敏感字段如登录密码暴露出去。异步处理不关键流程比如签约成功后发送通知短信用Async异步执行不要让用户等一个短信发送的耗时。6.4 上线部署的几个实用建议打包用Maven的package命令打出来的jar包直接用nohup java -jar xxx.jar 启动注意加-Xms512m -Xmx512m参数限制内存占用学生机2G内存的云服务器跑这个项目绰绰有余。数据库文件定时备份写个cron脚本每天凌晨把MySQL的dump文件备份到对象存储别等数据丢了才后悔。Nginx转发请求时配一个client_max_body_size 10m否则上传图片稍微大一点就报413。7. 一个容易忽视但加分的设计种子数据与演示模式这个点很少有人讲但对毕业设计、课程设计这种场景非常实用。答辩演示时最尴尬的情况就是系统中没有数据页面空空如也功能无法演示。我强烈建议你在项目里内置一套数据初始化组件。做法很简单写一个实现了CommandLineRunner的类应用启动时检测数据库里是否有数据如果没有自动生成一批模拟数据20个学生账号密码统一为1234565个房东账号每人在不同区域发布3-5套房源部分房源带有预约记录、合同记录和账单记录状态覆盖待确认、已确认、已完成、已取消生成近6个月的统计数据这样管理后台的趋势图、饼图看起来非常饱满。这样每次清空数据库重新启动系统自动恢复成“可演示状态”不用手工造数据是一个非常讨巧但很务实的功能。8. 做完这个项目之后还可以怎么扩展项目完成后如果你有精力可以沿着几个方向做一些“加分扩展”不用全做挑一两个就足以在答辩时展示你的思考深度地图找房接入高德地图Web服务房源列表旁边显示地图房子以Marker形式标记点击弹窗显示简要信息。实现不难但展示效果非常直观。推荐算法基于用户浏览和收藏记录做一个简单的“猜你喜欢”推荐用协同过滤的简化版本就能实现不需要太重的大数据组件。webSocket消息通知预约确认、合同到期、账单生成等事件通过WebSocket实时推送给用户比轮询体验好很多。微信小程序端学生端完全可以做一个小程序版本调用后端接口复用一套逻辑。小程序开发门槛不高但视觉冲击力强属于答辩时的“王炸”。我个人做下来的体会是这个项目麻雀虽小但五脏俱全——典型的业务系统该有的东西它都有了。如果能把上述任何一个进阶方向做实、做透它的含金量不亚于一些所谓的企业级项目。关键在于你真的从业务出发去设计而不是对着网上的开源项目复制一份。数据库建模体现业务理解核心接口体现编码功底异常处理和安全设计体现工程素养这三样抓住了答辩的时候心里是有底的。
网站建设高端定制企业官网