SSM框架房屋租赁系统实战复盘:从数据库设计到并发事务
做Java后端这几年我先后跟过几个不同的业务系统要说技术栈覆盖最全、最值得认真写一遍的还是SSM框架的房屋租赁系统。Spring、SpringMVC、MyBatis这三件套组合在一起几乎没有一块是多余的——从数据表设计到事务控制从权限拦截到动态SQL整个开发链路能把你对Java Web的理解从头到尾重新梳理一遍。这篇文章就是我当时开发这个房屋租赁系统的完整复盘包含需求拆解、表结构设计、核心代码思路以及前后端联调时踩过的那些坑。不管是准备做课程设计的学生还是想拿一个完整项目练手的初级开发者这份记录应该都能帮你少走不少弯路。1. 为什么这个项目我会坚持用SSM框架而不是Spring Boot1.1 SSM三件套到底各管什么很多刚入门的同学一听说SSM就觉得老觉得现在新项目都是Spring Boot起步。这么说也没错但如果你真的想搞懂一个Java Web应用是怎么跑起来的SSM这种“拆开式”的架构反而是最好的教科书。Spring管的是对象生命周期和组件之间的依赖关系。房屋租赁系统里大大小小的Service、Mapper、Controller如果不交给Spring容器统一管理你会发现自己每天都在new对象、手动维护依赖代码很快就成一团乱麻。Spring的IoC容器把这些对象的创建和组装全部接管你要做的只是标注好Component、Service、Autowired剩下的注入过程完全不用操心。SpringMVC管的是HTTP请求的分发和响应。用户在浏览器里输入URL、点击按钮请求怎么找到对应的Controller方法、参数怎么绑定、返回值怎么转成JSON这套流程全靠DispatcherServlet在前端控制器里做路由分发。理解了这个机制你就知道为什么Controller层的方法签名能直接接收前端传来的参数也能明白为什么404、500这类错误有时候和你的业务逻辑毫无关系而是映射路径没写对。MyBatis管的是Java对象和数据库表之间的映射。它没有像Hibernate那样把表结构完全对象化而是让你自己写SQL但又帮你把ResultSet到实体类的转换过程自动化了。房屋租赁系统里有大量多条件组合查询比如按区域、价格区间、户型、朝向筛选房源这种需求用MyBatis的动态SQL写起来非常顺手SQL的可读性和可控性都远好过拼接字符串。1.2 不直接上Spring Boot的理由当时团队里也讨论过要不要直接上Spring Boot毕竟它内嵌Tomcat、自动配置、约定大于配置开发效率确实高很多。但我坚持用SSM原因有两条。第一Spring Boot把太多东西变成了默认值。内嵌容器、自动装配、默认的配置项看起来省事可是当一个请求异常时你很难快速判断到底是哪一层出了问题。而SSM的方式是显式的web.xml里注册DispatcherServlet、Spring容器加载配置文件、MyBatis的SqlSessionFactory自己创建每一环都看得见摸得着。这种“麻烦”在排错的时候全是优势。第二房屋租赁系统的业务复杂度不算低但又没高到必须引入微服务那一套。用户、房源、合同、订单、收藏、评论核心表就六七张但表之间的状态流转很复杂。用SSM一把梭事务边界、SQL语句、接口设计全都在自己掌控内适合做精细化控制。后来系统上线跑稳定了再迁移到Spring Boot时底层逻辑几乎不用动只是去掉一堆XML配置这种平滑过渡的底子就是SSM阶段打下来的。提示如果你是初学者建议先用SSM完整做一遍理解透了再换Spring Boot。直接上手Spring Boot确实快但出了问题你会连排查的方向都找不准。底子这件事偷懒不得。2. 房屋租赁系统需求拆解从业务到数据库2.1 角色、状态与核心业务流程任何一个业务系统动手写代码之前先把角色和流程理清楚后面能省一大半返工时间。房屋租赁系统的角色不复杂就三类管理员、房东、租客。管理员负责审核房源、管理用户、处理投诉偶尔还要维护一下首页的轮播图和公告。房东的核心操作是发布房源、修改房源信息、处理租客的看房预约、确认签约。租客这边就是浏览房源、搜索筛选、收藏房源、预约看房、签约付款、退租。这些角色串起来核心业务闭环大概是这样的房东录入房源 → 管理员审核通过 → 房源上架展示 → 租客按条件检索浏览 → 租客提交看房预约 → 房东确认预约时间 → 双方线下看房 → 确认意向后签约 → 生成订单并支付押金和首月租金 → 合同生效进入租期中 → 租期结束退租结算。这里最容易被新手忽略的是订单的状态流转。我当时把订单表的状态设计成一组常量待预约、待看房、已签约、已退租、已取消。每一个状态之间都有明确的触发动作比如只有房东确认了预约状态才能从待预约变成待看房只有双方都确认了签约订单才能变成已签约。状态机这种东西看起来只是几个数字但在后续处理列表筛选、统计报表时你会发现一个设计良好的状态字段能省掉无数个复杂SQL。2.2 数据库表设计的关键取舍数据库设计是整个系统里最不能马虎的环节。我当时设计了七张核心表这里把每张表的定位说清楚。用户表user存的是三类角色的公共信息账号、密码、手机号、邮箱、角色标识、注册时间。密码我用的MD5加盐存储加盐这个点要特别提一句——直接用MD5存密码碰上彩虹表基本等于裸奔。房源表house的内容比较多标题、描述、户型、面积、朝向、楼层、所在小区、区域、租金、押金、出租方式整租/合租、配套设施这些基础字段都有另外还加了状态字段来区分待审核、已上架、已下架。图片这块我单独建了一张房源图片表一个房源对应多张图片这样后续做轮播图或者缩略图都好扩展。订单表order是核心业务表关联了房源和租客还记录了预约看房时间、实际看房时间、签约时间、合同编号这些节点。合同表contract和订单是一对一关系主要存租期起止时间、月租金、押金、违约金比例、合同内容文本。之所以把合同单独拆出来是因为合同一旦生成就基本不再变更和订单这种经常要更新状态的表分开有利于数据稳定和后续查询。还有收藏表favorite、评论表comment和公告表notice这三张相对简单但也要注意索引。收藏表里我对用户ID和房源ID建了联合唯一索引防止同一用户重复收藏同一套房源。这种细节点很容易漏但漏掉之后就会出现脏数据清理起来特别痛苦。有一件事我纠结了很久房源表和订单表之间要不要直接冗余一些字段比如在订单表里直接存租金金额和房源标题最后我选择了冗余。原因是订单生成之后房东很可能会修改房源信息如果订单表实时关联房源表历史订单的数据就会漂移。把快照字段冗余到订单表里订单永远显示生成那一刻的价格和标题这才是用户真正想看到的数据。这个经验在后面的账单核对中起到了关键作用。注意设计数据库时别只盯着“能存数据”要想清楚“数据变了以后怎么办”。房租、标题、图片这类字段在订单、合同里必须做快照冗余否则线上跑半年你就会被历史数据不一致的问题折磨到崩溃。3. 核心业务模块的落地实现从登录鉴权到房源管理3.1 登录、验证码与权限拦截器房屋租赁系统有三类角色权限天然不同所以登录鉴权是第一个要做的模块。我在SpringMVC里注册了一个登录拦截器核心逻辑很简单在拦截器的preHandle方法里判断当前Session是否有登录用户没有就重定向到登录页有的话再判断用户角色是否允许访问当前URL。这里有个细节容易被忽略静态资源要放行。CSS、JS、图片这些资源不需要登录也能访问如果拦截器配置时图省事写成拦截所有路径你会发现登录页渲染出来没有样式白白浪费半天排查时间。验证码我用的是Java原生实现的方案后端生成一个随机字符串存入Session同时通过BufferedImage绘制成图片返回前端。前端提交登录表单时带上验证码字段后端比对Session里的值和表单提交的值是否一致。这个流程不复杂但要注意比对完之后立刻把Session里的验证码清掉否则同一个验证码可以被重复使用存在逻辑漏洞。密码校验这一层我前面提到用了MD5加盐。实际上比对逻辑是根据用户输入和该用户专属的盐值重新计算MD5再和数据库里存的值比对。这样就算数据库泄露攻击者也很难通过预计算的彩虹表反推出原始密码。3.2 房源发布与图片上传房东发布房源是一个典型的多表操作插入房源主表信息同时插入图片记录。这里第一版我犯过错误把图片逻辑写在Service里用for循环逐条插入结果一次发布十张图片就要执行十次INSERT性能很差。后来我改成MyBatis的批量插入一条SQL把图片列表一次性写入耗时从几百毫秒降到了几十毫秒。图片上传用的MultipartResolver配置。上线前我特意确认了文件大小限制——Tomcat默认的上传限制通常是2MB对房源图片来说明显不够。我在SpringMVC配置里把maxUploadSize设成了10MB同时在前端也做了文件大小和格式的预校验避免用户传个40MB的原始照片直接打爆服务器。图片存储我选的是本地磁盘路径配置里用一个静态映射把上传目录映射成URL前缀。这个方案在单机部署时够用后面如果要做负载均衡就得换成OSS或者云存储了。项目里还做了一个处理上传时把图片压缩一份缩略图列表页加载缩略图详情页加载原图页面打开速度能快不少。3.3 房源条件检索与分页房源检索是整个系统里查询最复杂的一个功能。前台页面有区域下拉框、租金区间输入、户型选择、朝向选择、关键词搜索这些条件组合起来可能有几十种排列组合。如果每个组合都写一条SQL代码量会爆炸如果只写一条SQL然后用if判断拼接又容易拼出语法错误。MyBatis的动态SQL正是解决这个痛点的。我在Mapper XML里写了一个selectHouseList标签内部用where标签加上多个if条件。区域不为空就根据区域匹配租金区间不为空就加价格范围户型不为空就加户型条件关键词不为空就用like去匹配标题和描述。MyBatis的where标签会自动处理前缀的AND OR这个设计比在Java代码里拼字符串不知道高明到哪里去了既安全又清晰。分页一开始我用的是手写LIMIT #{offset}, #{pageSize}后来觉得每次都要算偏移量太烦就引入了PageHelper插件。这个插件用起来极其方便只要在查询前调用PageHelper.startPage(pageNum, pageSize)紧接着的第一条查询就会自动带上分页还能通过PageInfo拿到总记录数。但这里我要提醒一句PageHelper的拦截机制是基于ThreadLocal的如果startPage和查询之间隔了其他数据库操作分页就失效了还会串数据。这也是我后面踩过的一个坑后面细说。4. 订单状态流转中的事务、并发与动态SQL深水区4.1 订单生成时的事务边界订单模块是整个系统里事务最复杂的部分。租客确认签约时要创建订单记录、更新合同信息、修改房源状态、给房东生成一条通知这四步必须绑在同一个事务里。如果中间任何一步失败前面已经写入的数据就得全部回滚否则会出现“订单创建了但房源状态还是已上架”这种数据不一致。实现上其实不复杂就是在Service方法上加Transactional注解。但有一个细节新手很容易踩事务只对运行时异常RuntimeException默认回滚如果方法里抛的是受检异常比如IOExceptionSpring默认是不回滚的。我当时就因为这个原因在订单接口里调用一个发送邮件的工具类邮件服务器连不上时抛出Exception订单已经提交成功了数据库里却还是失败的中间状态。排查了整整一天才发现是事务回滚策略的问题最后在Transactional注解里明确写了rollbackFor Exception.class才解决。这个问题的本质是Spring事务拦截器默认只回滚Error和RuntimeException因为受检异常在业务上可能“不是必须回滚的情况”。但在我们的场景里邮件发不出去就是应该回滚所以这个配置不能省略。4.2 同一房源被重复签约的并发问题房屋租赁系统有一个非常典型的并发场景一套热门房源被两个租客同时看中两个人在同一秒点击了签约按钮。如果没有并发控制数据库层面就可能出现两条都显示“签约成功”的订单房源状态变成已出租但合同却生成了两份。第一版我天真地以为在Java代码里判断一下房源状态就够了结果线上环境用JMeter一压并发一高就出问题。原因是Servlet是线程不安全的两个请求同时进到Service方法里先读状态都是待出租然后都往下执行最后都写入了订单。这个问题的本质是“检查再执行”的竞态条件单靠业务代码根本挡不住。最后我用了两个层面的控制数据库层面给房源表的房源状态字段加了一个条件更新UPDATE house SET status 已出租 WHERE id #{houseId} AND status 待出租影响行数为0就说明已经被抢走直接抛业务异常。同时再配合Spring的事务隔离级别把订单生成Service的隔离级别设置为READ_COMMITTED确保读取到的房源状态不是脏数据。这套组合下来并发场景才算真正稳了。4.3 复杂报表查询的动态SQL实践系统里还有一个比较吃SQL的模块管理后台的运营统计。需要按房源维度统计每个房源的预约次数、签约次数、累计租金还要按周和月做聚合。这种报表需求用MyBatis的动态SQL来做比较合适我通过choose when else这组标签实现了时间范围的动态切换。具体逻辑是如果传入了开始时间和结束时间就按这个区间过滤如果只传了月份就自动算成当月一号到月末如果一个时间都没传默认查最近30天。这样前端只需要传一个模糊的条件后端把边界都处理好SQL的可复用性很强。这类动态SQL写的时候一定要在本地把各种组合都测试一遍尤其是choose when else这种分支逻辑很容易出现“都没匹配到默认分支”的情况导致查出来的结果比预期多很多。我后来干脆写了一个单元测试把所有条件组合都覆盖了一遍虽然前期费了点时间但后面改需求的时候安全感十足。5. 开发过程中踩过的坑与完整排查链路5.1 页面中文乱码从Tomcat到MySQL的层层问题中文乱码这个坑我在项目初期几乎是必踩。现象是页面上显示的中文全部变成问号后台日志里打印的中文也是乱码数据库里存进去的数据同样有问题。排查链路我一步步拆开来看。先查MySQL连接串在jdbc.url里加上了useUnicodetruecharacterEncodingutf8这是最常见的错误源头。然后查服务端请求编码在SpringMVC里配置了CharacterEncodingFilter强制设置请求和响应的编码为UTF-8。这里有个值得注意的点这个过滤器一定要注册在DispatcherServlet之前否则请求参数已经被解析过了过滤器再设置编码就晚了。数据库表本身也要检查。MySQL的表和字段的默认字符集如果还是latin1那即使程序端全部UTF-8存进去还是乱。我在建表语句里显式指定了DEFAULT CHARSETutf8mb4注意这里用的是utf8mb4而不是utf8因为它支持完整的Unicode包括emoji字符。最后还检查了Tomcat的server.xml里Connector的URIEncoding属性虽然现代Tomcat版本默认就是UTF-8但老版本不配置就会乱码。这四层全部检查一遍之后乱码问题才算彻底根治。5.2 上传文件大小超出限制这个坑出现在房屋图片上传模块。当时前端上传一张手机拍的房源照片后台直接报MaxUploadSizeExceededException异常前端弹窗提示语也写得非常含糊用户完全不知道发生了什么。第一反应就是检查SpringMVC的文件上传配置把maxUploadSize从默认的2MB调大。改完之后本机上测试通过但部署到服务器又出问题。这次不报文件大小异常了而是Nginx返回413状态码。查了Nginx的文档才知道Nginx默认对客户端请求体大小也有限制需要在配置里加client_max_body_size 10m。这个经验很重要一个上传功能可能卡在不同层的限制上本机测通不代表服务器环境也OK。一旦遇到413优先查反向代理的配置而不是死磕Java代码。5.3 SQL注入隐患${}和#{}的区别MyBatis的Mapper XML里有两种参数占位符#{}和${}。它们看起来只是写法不同实际意义天差地别。我在项目里用了#{}它会被解析成预编译的占位符?由JDBC的PreparedStatement处理这种方式天然防SQL注入但${}是直接字符串拼接把参数值直接嵌进SQL里。当时项目里有一个排序功能前端传排序字段和排序方向我图方便用${}拼了进去。虽然功能跑通了但后来做安全审计时发现这个写法存在注入风险——如果排序字段直接拼进ORDER BY攻击者完全可以构造恶意参数。正确的做法是对参数做白名单校验比如在Java代码里把允许排序的字段名映射成固定的数据库列名再拼进SQL而不是相信前端传来的任何字符串。这个坑特别容易出现在棉试项目里面试官只要看到Mapper里有${}基本都会追问一句怎么防注入。我当时就被问过回答完白名单方案之后对方还追问了一下为什么要用白名单而不是黑名单因为白名单是先定义“允许什么”黑名单是“禁止什么”前者的安全性天然高一档。5.4 PageHelper分页失灵导致的数据串页这个坑我必须单独写一节。现象非常诡异列表页第一页显示正常第二页开始数据就乱了有时候显示的是第一页的内容有时候第一页的内容混进来了翻到第三页更离谱直接重复第二页的数据。排查过程是这样的先确认SQL本身没问题单独在数据库客户端执行查询结果是对的。再确认PageHelper版本和依赖没问题单独写一个测试类分页也是对的。后来我发现原来问题出在Service层——我在调用PageHelper.startPage之后并没有马上执行查询而是先做了一堆权限判断、参数组装中间还循环调用了几次其他Mapper的查询。PageHelper的ThreadLocal机制是startPage之后的第一条真正执行的查询语句才会被加上分页SQL如果中间穿插了别的查询分页就加错了对象。解决办法也很简单把startPage紧贴到目标查询之前中间不要执行任何其他数据库操作。但更彻底的做法是分页查询单独封装成一个方法页面逻辑、业务判断、权限校验全部放在方法外部完成从结构上保证startPage和查询之间不可能插入其他SQL。5.5 Session失效导致的前端跳转问题系统上线后收到用户反馈登录状态隔一段时间就丢失最常见的情况是用户填了一大段房源描述点保存的时候突然被踢回登录页填的内容全部没了。这个问题的根源是Session的默认过期时间。Tomcat的Session默认超时时间只有30分钟而管理员后台填写房源信息本来就耗时填着填着超过30分钟Session就失效了。第一个方案是把Session超时时间调长到60分钟但这只是缓解更合理的处理方式是前端定时向后端发一个心跳请求只要有操作就刷新Session的存活时间。同时前端做自动保存草稿这样即使Session真的失效用户辛苦填的内容也不会丢。从用户体验的角度讲技术上再合理的“踢下线”对用户来说都是灾难。所以我现在做项目凡是涉及长表单的场景一定会提前考虑Session续期和草稿保存这两个补丁加上之后用户的流失率明显下降了。6. 前后端联调与接口规范从能跑到能用6.1 统一返回格式与状态码设计项目进入联调阶段之后我发现前后端团队协作效率其实取决于接口格式是否统一。当时定了一个非常简单的规范所有接口返回一个Result对象包含code、message、data三个字段。code为200表示成功其他为业务错误或者服务端错误。业务错误码也做了分类比如1001是参数错误2001是未登录2002是权限不足3001是业务逻辑错误比如房源已被预订。这套规范的价值在联调的时候体现得淋漓尽致。后端只要保证接口返回这个统一格式前端就能用一个统一的拦截器处理所有错误提示——code不为200就弹message不用每个页面单独写错误处理逻辑。后来我还在拦截器里做了一层统一异常处理器把Controller层抛出的业务异常自动翻译成对应的Result返回代码里不用到处try catch整洁很多。6.2 接口文档与联调时的三个细节接口文档用的是Swagger注解Controller类上标注Api方法标注ApiOperation参数标注ApiParam。这个过程中我发现写文档最大的价值不是给外部看而是给自己理清接口边界。很多定义不清的逻辑写文档的时候就会暴露出来——比如“预约看房”接口到底由谁触发房东确认还是租客发起这些在写文档时明确了后面就不会出现改来改去的情况。联调时还遇到过三个细节问题。第一个是跨域。开发环境前端在8080端口后端在8081端口浏览器因为同源策略直接拦截Ajax请求。我配置了SpringMVC的CORS跨域支持允许特定域名访问生产环境则是通过Nginx反向代理让前后端共用一个域名从根源上规避跨域问题。第二个是日期格式。前后端约定所有时间字段统一用yyyy-MM-dd HH:mm:ss格式通过继承ObjectMapper重写日期序列化器实现避免前端拿到时间然后自己格式化半天还出错。第三个极其容易忽略后端接口改动之后前端浏览器缓存了旧版JavaScript文件导致页面上调用的还是老接口。后来我在静态资源URL上加了版本号参数每次发版改一下版本号就好。6.3 日志规范从System.out到AOP切面开发初期我习惯用System.out打印关键信息上线前全部改成Logback日志。业务日志分了三类访问日志、操作日志、异常日志。访问日志记录了每个请求的URL、参数、耗时、响应码操作日志记录了管理员和房东的关键操作异常日志统一打印异常堆栈。比较得意的是我用AOP做了一个审计切面。定义一个AuditLog注解标注在需要记录日志的Controller方法上切面里自动获取当前登录用户、请求参数、执行结果统一写入操作日志表。这种做法让日志代码和业务代码完全解耦Controller里一个注解搞定不需要在每一个方法里手写日志记录代码。管理员后台也顺便做了一个“操作日志查询”页面出了问题可以倒查是哪个用户什么时候做了哪些操作这在项目交付的时候也成了加分项。7. 系统上线之后的优化方向与个人感受7.1 房源地暖缓存从MySQL到并发穿透上线之后访问量慢慢上来最先扛不住的是首页的热门房源接口。这个接口每次请求都去MySQL里查询数据库连接池一度被打满接口响应时间从50ms飙升到1秒以上。第一版优化走了简单粗暴的路径加Redis缓存。把热门房源列表缓存到Redis设置5分钟过期过期后回源数据库重新加载。加完缓存之后问题减少了一大半但紧接着又碰到缓存穿透——大量请求用一个不存在的房源ID直接打后端因为缓存里也没有这个key每次都落到数据库。解决办法是缓存空值把不存在的ID也缓存起来可以设置较短的过期时间。更极端的做法是加布隆过滤器把存在的ID全部加载到布隆过滤器里面不存在的请求在Redis这一层就返回了。但这里要泼一盆冷水如果项目还没到那个量级别过度设计。我当时在单个房源详情接口里加了Redis缓存和布隆过滤器后来发现这个接口本身请求量没那么大加了缓存反而增加了代码复杂度。真正需要缓存的是列表页和详情页热数据其他接口保持简单查询就好。优化的优先级应该是先看数据库慢查询日志把慢SQL的索引补齐再考虑加缓存别一上来就上重武器。7.2 定时任务与房租提醒房屋租赁系统后期我加了一个比较实用的功能房租到期提醒。每天凌晨两点通过Spring的Scheduled定时任务扫描合同表把未来7天内需要交租的合同找出来给租客和房东分别发送提醒消息。实现的时候踩了一个小坑定时任务在单机环境下没问题但如果将来做多节点部署同一个任务会被每个节点都执行一遍导致用户收到重复提醒。解决办法有几种最简单的是通过配置文件开关控制只有一个节点开启定时任务复杂一点的是用分布式锁。这个项目规模还不大我选择了配置文件开关方案并且在代码里加了一个防止重复执行的标识。7.3 我的几点真实体会项目做完回头看有三点感受特别深。第一SSM框架本身不难难的是把数据库设计、事务边界、SQL这几条线串起来。这个项目做完之后再去理解Spring Boot的自动配置很多东西一下子就能对上了因为你知道它帮你省掉的是哪几部分工作。第二遇到测不出来但在线报错的并发问题永远先从事务隔离级别和数据库锁这两个角度想。我在做这个项目的时候有一半的排错时间花在验证“是不是事务没有生效”“是不是索引没建”“是不是并发把状态写坏了”这些问题都属于基础功但恰恰是这些基础功决定了系统的稳定性。第三做完一个完整的项目和管理后台比刷十套面试题都管用。面试官问你SSM框架的时候你直接讲这个系统里“我是怎么处理同一房源被重复预订”的案例比背IoC和AOP的定义有说服力太多了。如果让我再选一次我还是会用SSM来做这个系统。不是因为别的就是因为它把Java Web开发的核心知识点都明明白白摊在了桌面上。这套东西弄明白了往后学什么框架都快。