ARTICLE DETAIL

资讯详情

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

Spring MVC+MyBatis+MySQL实战:定制旅游系统开发详解

Spring MVC+MyBatis+MySQL实战:定制旅游系统开发详解 前阵子接了一个私人定制旅游网系统的项目需求方把技术栈定得死死的Spring MVC MyBatis MySQL明确说了不要 Spring Boot、不要微服务。第一反应是这套组合太复古了但真动手做下来才发现越是这种经典技术栈越能检验后端基本功。项目核心场景是用户在线提交出行需求系统根据目的地、预算、天数、同行人类型做线路匹配与报价然后走完下单、支付回调、定制师派单、行程确认的完整闭环运营人员在后台维护线路、管理订单、处理需求单。这篇文章就把我从数据库设计、框架整合、核心业务代码到问题排查的完整过程记录下来适合正在做 JavaWeb 课程设计、毕业设计或者刚接手公司老 Spring 项目的同学参考哪怕你是第一次接触这套技术栈也能照着把项目搭起来。1. 项目背景与技术选型思路1.1 需求拆解私人定制旅游的核心流程私人定制旅游和传统搜索-下单-支付的标品旅游有很大区别。标品旅游是已经有固定线路用户直接买定制旅游是先有需求再生成方案。所以系统流程天然是两段式前段是需求收集与匹配后段是订单履约与管理。前端面向普通用户核心页面有这几个注册登录、定制需求表单、推荐线路列表、线路详情、提交订单、我的订单。定制需求表单是关键字段包括目的地支持多个、出行天数、预算区间、同行人数、同行人类型亲子/情侣/老人/朋友结伴、偏好标签美食、购物、自然风光、文化历史等。后端面向运营和管理员核心功能是线路管理增删改查、上下架、需求单管理查看、匹配线路、派给定制师、订单管理确认、改价、取消、定制师管理、基础统计。这里有一个容易忽略的细节需求单不是提交完就结束它有一个状态流转——待匹配、已推荐、已派单、已生成方案、已转化订单、已关闭。这个状态字段必须设计进需求表否则运营后台没法做待办筛选。我在做这类系统时习惯把所有状态统一用 tinyint 存储并在代码里用一个枚举类维护不在数据库里写魔法数字的注释这样后续扩展状态不会乱。1.2 技术栈选型这套老组合为什么还值得做Spring MVC MyBatis MySQL 的组合在今天看起来确实不够时髦但选择它有三个实际理由。第一团队或客户对这套技术栈最熟。很多中小公司、外包项目的存量代码就是这套新项目为了维护成本低也会继续沿用。Spring Boot 虽然简化了配置但遇到老项目迁移不懂 Spring MVC 的初始化原理根本无从下手。第二MyBatis 在复杂业务查询上非常灵活。定制旅游的线路匹配涉及多条件组合、多表关联、动态 SQL用 MyBatis 的where、if、foreach标签可以优雅地解决。相比之下 JPA/Hibernate 在多表动态查询上要么写 JPQL要么写 Specification学习成本和排障成本都更高。第三MySQL 是关系型数据的事实标准订单、支付、库存这类强一致性数据用 MySQL 的事务机制最稳妥也最容易招到能维护的人。Spring MVC 的核心价值在于清晰的职责分层Controller 只做参数接收和结果封装Service 做业务逻辑和事务控制DAO/Mapper 只做数据访问。这种分层配合接口定义可以让前端、后端、测试并行工作。在项目里我还用了一个额外的好处遇到接口返回结构要调整时只需要改 Controller 层的统一返回类不需要动 Service 层。这也是老框架的工程智慧——代码写起来不酷但维护起来舒服。2. 数据库建模与核心表设计2.1 核心表结构与字段规划数据库设计是这类项目的地基。我建了 7 张核心表这里只列出最关键的设计要点。用户表t_user用户 ID 主键自增、用户名、密码MD5 加盐不用明文、手机号、邮箱、注册时间、用户状态。加盐的规则我习惯用固定盐值 用户 ID 密码拼接后再做 MD5避免两个用户相同密码产生相同密文。定制需求表t_custom_demand需求 ID、用户 ID、目的地一个用户可能想去多个城市用逗号分隔字符串存、预算下限、预算上限、出行天数、同行人数、同行人类型、偏好标签、需求描述、状态、创建时间。预算用两个整数区间字段不要只存一个预算范围的字符串否则后续 SQL 做区间匹配非常痛苦。线路表t_route线路 ID、标题、目的地、行程天数、市场价、定制价、封面图、行程亮点、线路状态草稿/上架/下架、库存、创建时间。线路详情页还要展示每天的行程安排所以单独建了行程表t_route_schedule行程 ID、线路 ID、第几天、标题、交通方式、餐饮安排、住宿描述、景点内容。订单表t_order订单 ID、订单编号业务编号唯一索引、需求 ID、线路 ID、用户 ID、订单金额、状态待支付/已支付/已确认/行程中/已完成/已取消、下单时间、支付时间、支付流水号、备注。订单编号不能直接用自增 ID因为业务上需要对外暴露编号防止别人通过 ID 遍历你的订单量。定制师表t_designer和派单表t_order_assign是运营侧的定制师 ID、姓名、专长标签、评分、当前待办数派单记录包括派单 ID、订单 ID、定制师 ID、派单时间、状态。2.2 几个关键设计决策订单号、金额、状态字段有些设计决策是踩过坑之后才形成的这里展开说一下。订单金额必须用DECIMAL(10,2)不能用 float 或 double。float 在二进制下无法精确表示小数累计金额比较时会出现 0.1 0.2 ! 0.3 的诡异问题。支付金额一旦出错就是事故所以从根上避免。如果涉及多币种可以再加一个币种字段按当时的汇率存本币金额。订单号和 ID 分离这件事有些同学觉得冗余。实际上订单号承担的是业务唯一标识ID 承担的是数据库主键。自增 ID 会暴露系统订单量也容易被批量爬取订单号则可以加入时间戳、随机序列。我生成的规则是yyyyMMddHHmmss 6位随机数并用唯一索引约束生成冲突时重试即可。状态字段我统一用TINYINT存数值配合 MySQL 的枚举注释不够还得在代码里做映射。比如订单状态0 待支付、1 已支付、2 已确认、3 行程中、4 已完成、5 已取消。这类字段不要直接在 SQL 里写WHERE status 3而是通过常量类或枚举引用字段名对应的值后续加状态或者在 SQL 里误写数字都能快速发现。2.3 线路推荐的动态查询 SQL 设计与索引线路推荐是这个系统的核心技术点。用户提交需求后系统要按目的地、预算区间、天数范围、偏好标签筛选可推荐的线路。MyBatis 的动态 SQL 在这里体现得淋漓尽致SELECT r.route_id, r.title, r.destination, r.days, r.market_price, r.custom_price FROM t_route r WHERE r.status 1 if testdestination ! null and destination ! AND r.destination LIKE CONCAT(%, #{destination}, %) /if if testbudgetMin ! null AND r.custom_price gt; #{budgetMin} /if if testbudgetMax ! null AND r.custom_price lt; #{budgetMax} /if if testdays ! null AND r.days lt; #{days} /if ORDER BY r.custom_price ASC注意两个细节一是gt;和lt;是 XML 里的转义写法直接写会被 XML 解析器当成标签结束符直接报错这是 MyBatis Mapper 文件里新手必踩的坑二是LIKE用在目的地模糊匹配上可以接受但如果表数据量上来应该对目的地字段建索引或者改成精确匹配加关联表的方式。索引设计上我建了这几个t_order的order_no唯一索引、user_id普通索引t_custom_demand的user_id索引t_route的destination status复合索引。复合索引的字段顺序有讲究destination放在status前面是因为业务查询通常先按目的地过滤再叠加状态过滤MySQL 的联合索引遵循最左前缀原则这两个字段的顺序要和常用查询条件对齐否则索引会失效。3. 项目整合与配置实操3.1 Spring MVC 骨架搭建与配置文件加载顺序很多人搭 Spring MVC 项目会卡在配置文件上本质原因是搞不清楚加载顺序。这里我完整走一遍。项目基于 Maven标准的webapp目录结构。核心配置文件有四个web.xml、spring-mvc.xml、spring-dao.xml、mybatis-config.xml。web.xml是入口负责启动 Spring 容器和 MVC 容器。关键配置顺序是ContextLoaderListener先启动根 Spring 容器加载 service、dao然后DispatcherServlet启动 MVC 子容器加载 controller。子容器能访问父容器的 Bean反过来不行。所以事务、数据源、Mapper 必须放在父容器spring-dao.xmlController 放在子容器spring-mvc.xml这个顺序搞反了会出现Service 注入不进去或事务不生效的问题。context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-dao.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping中文字符集过滤必须在DispatcherServlet之前注册否则 POST 请求参数会乱码。这里是老生常谈但每次都有项目栽在这filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filterspring-mvc.xml里配置component-scan扫描controller包、annotation-driven开启注解支持、InternalResourceViewResolver配置 JSP 视图前缀后缀。spring-dao.xml里配置数据源、SqlSessionFactoryBean、MapperScannerConfigurer、DataSourceTransactionManager。这两份配置各管各的Bean 不会互相串。我在搭建时喜欢把spring-dao.xml里的数据源单独抽成jdbc.properties这样换环境只改一个文件不会动到 Java 代码。3.2 MyBatis 初始化原理与核心配置项讲 MyBatis 的配置先提一个热词XMLConfigBuilder。MyBatis 启动时SqlSessionFactoryBuilder会调用XMLConfigBuilder解析mybatis-config.xml里的每一个配置节点把settings、typeAliases、mappers等解析成Configuration对象再构建DefaultSqlSessionFactory。理解这个过程的意义在于MyBatis 所有的配置项本质上都是在修改Configuration这个内存对象你写在 XML 里的每一项都能在Configuration类里找到对应属性。以后遇到配置写了没生效的问题第一反应就应该是这个配置项是不是对应的解析代码没执行还是被后面的覆盖了。mybatis-config.xml里我重点配置这几个选项settings setting namemapUnderscoreToCamelCase valuetrue/ setting namelogImpl valueSTDOUT_LOGGING/ setting namecacheEnabled valuefalse/ /settingsmapUnderscoreToCamelCase把数据库的create_time自动映射到 Java 属性createTime省掉一堆resultMap。logImpl设成STDOUT_LOGGING可以在控制台直接打印 SQL 和参数排查问题效率翻倍。cacheEnabled我在这个项目里默认关掉二级缓存理由后面第 5 节详细说。Mapper 的注册方式我用MapperScannerConfigurer指定basePackage后Spring 会把接口代理注册成 BeanService 里直接Autowired接口就行不需要手写 Mapper 实现类。这也是 MyBatis 面试题里经常问的MyBatis 的 Mapper 接口没有实现类为什么能直接注入——答案是 Spring 通过MapperScannerConfigurer扫描接口后用MapperProxy动态代理生成了实现。3.3 事务、连接池与日志配置这个项目用的是 Druid 连接池。配置里除了常规的driverClassName、url、username、password我还设置了连接池初始大小 5、最大 20、最小空闲 5以及连接泄漏检测。生产环境连接池参数不能拍脑袋要预估并发量一个 Tomcat 线程处理请求时通常会占用一个连接连接池最大连接数不能小于 Tomcat 最大线程数否则高并发下连接会排队等待接口响应时间飙升。事务配置在spring-dao.xml中声明DataSourceTransactionManager并开启注解驱动bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/然后在 Service 实现类或方法上标注Transactional。这里必须提醒一个坑事务默认只在RuntimeException和Error时回滚checked exception 不会回滚。如果业务里抛出一个自定义的BusinessException通常继承Exception事务是不会回滚的必须显式加Transactional(rollbackFor Exception.class)。我见过不止一个项目因为这个原因出现下单失败但库存扣了的事故。还有一个隐蔽的坑Spring 事务是基于 AOP 代理的同类内部方法调用不会走代理。比如OrderServiceImpl里createOrder()调用了同类里的deductStock()而deductStock上标了Transactional这个事务是不会生效的因为this.deductStock()直接调的是原始对象不是代理对象。解决方式是把需要事务的方法放到另一个 Service 里或者在类上标注Transactional让整个类参与事务。4. 核心业务代码实现4.1 需求提交与线路匹配Controller → Service → Mapper需求提交是整个定制流程的起点。前端表单提交后Controller 层做参数校验然后调用 Service 层。为了代码整洁我把校验分成两层参数格式校验用 JSR 303 注解在实体类上声明业务规则校验在 Service 里手动写。Controller 层代码风格保持统一所有接口返回一个统一的Result对象Controller RequestMapping(/demand) public class DemandController { Autowired private DemandService demandService; RequestMapping(value /submit, method RequestMethod.POST) ResponseBody public Result submit(RequestBody Valid CustomDemand demand, HttpSession session) { User user (User) session.getAttribute(loginUser); if (user null) { return Result.error(请先登录); } demand.setUserId(user.getUserId()); demandService.submitDemand(demand); return Result.success(); } }Service 层做核心业务逻辑。submitDemand里要做的动作包括校验目的地、预算区间、天数是否合理设置初始状态插入需求记录。这个操作只有一张表的插入但为了后续扩展审计日志我在这里就加了Transactional。线路匹配是另一块核心逻辑。用户提交需求后运营人员在后台点智能匹配系统根据需求参数查线路列表返回匹配结果。匹配的标准是目的地包含、预算区间有交叉、天数不超过需求天数、线路没有下架。匹配到的线路按匹配度排序匹配度计算规则是目的地命中 30 分预算命中 40 分天数命中 20 分偏好标签命中每个 5 分满分 100。这个评分逻辑不复杂但它把业务规则从 SQL 里剥离开来后续调整权重只改一行常量不用动 SQL。public ListRouteMatchResult matchRoutes(Long demandId) { CustomDemand demand demandMapper.selectById(demandId); ListRoute routes routeMapper.selectByCondition( demand.getDestination(), demand.getBudgetMin(), demand.getBudgetMax(), demand.getDays()); return routes.stream() .map(route - new RouteMatchResult(route, calcMatchScore(demand, route))) .sorted(Comparator.comparingInt(RouteMatchResult::getScore).reversed()) .collect(Collectors.toList()); }4.2 订单状态机与支付回调幂等订单是这个系统里最需要谨慎的部分牵扯到资金。我的设计是用一个状态机类统一管理订单状态流转避免在各处 Service 里散落if status 1 then set status 2这种逻辑。状态机的核心是一张流转表定义当前状态到目标状态的合法性。public enum OrderStatus { UNPAID(0), PAID(1), CONFIRMED(2), TRAVELLING(3), COMPLETED(4), CANCELLED(5); private final int value; OrderStatus(int value) { this.value value; } public boolean canTransitTo(OrderStatus target) { switch (this) { case UNPAID: return target PAID || target CANCELLED; case PAID: return target CONFIRMED || target CANCELLED; case CONFIRMED: return target TRAVELLING || target CANCELLED; case TRAVELLING: return target COMPLETED; default: return false; } } }支付回调是订单系统最容易出问题的地方。我的实现原则是回调必须幂等同样一笔支付结果微信/支付宝可能通知多次处理函数必须保证重复通知不会重复改状态、不会重复扣库存。实现方式是回调方法里先按orderNo payTradeNo查支付记录表如果已存在直接返回成功如果不存在整个处理逻辑放在一个事务里包括更新订单状态、写入支付记录、扣减线路库存、给定制师派单。回调接口还有一个隐藏细节返回给支付平台的响应必须明确。处理成功返回success失败要返回失败信息并允许平台重试。有些同学在处理失败时抛异常导致平台一直重试这是正确行为但要注意重试的并发——所以支付记录表上必须加唯一索引当两条相同回调并发进来时唯一索引能兜住重复插入保证只有一条能成功。4.3 后台管理定制师派单与线路管理运营后台用的是另一个 Controller 包按角色做权限控制。我用一个简单的拦截器实现登录和角色校验AdminInterceptor拦截/admin/**路径判断 session 里的用户是否具有管理员角色没有就重定向到后台登录页。这个方案对内部后台足够用不需要引入 Shiro 或 Spring Security。定制师派单逻辑相对简单需求单确认后系统从t_designer表里筛选专长与目的地匹配、当前待办数最少的定制师自动派单同时更新待办数。这个操作要保证同一个定制师不会被并发派到两个单子我用乐观锁处理派单时执行UPDATE t_designer SET pending_count pending_count 1 WHERE designer_id ? AND pending_count 5如果影响行数为 0说明已被其他事务更新换下一个定制师重试。线路管理里有个常见的实现——图片上传。Spring MVC 用CommonsMultipartResolver配置上传解析器Controller 里通过MultipartFile接收文件保存到服务器本地目录同时把访问 URL 存到数据库。这里的坑是上传目录必须和 Tomcat 的部署目录分离否则重新部署项目时图片会丢失。我把图片统一放到/data/travel/upload并在 Tomcat 的 server.xml 里配置虚拟目录映射/upload/**到该路径。数据库存的就是/upload/xxx.jpg这种相对路径前端直接拼接访问。换服务器时只需要迁移数据目录和项目部署包完全解耦。5. 常见问题与排查实录5.1 MyBatis 条件不生效与动态 SQL 细节项目运行阶段遇到过几次SQL 条件没生效的诡异情况最后发现全是动态 SQL 的细节问题。第一个是if testbudgetMin ! null对Integer类型的0值判断。当预算下限传 0 时budgetMin ! null为 true条件会正常拼接。但如果没用包装类型而用了intMyBatis 在 OGNL 表达式里对基本类型 0 的判空行为会异常! null永远为 true导致条件拼接但值不对。所以实体类里的数字字段一律用包装类型Integer不要用int。第二个是if testcompanionType 2这种字符串比较。在 OGNL 里单引号内的2会被识别为字符串但companionType是String时偶尔会出现比较失败。稳妥写法是testcompanionType 2或者testcompanionType 2.toString()。这个坑不是必现但线上条件不生效排查时非常浪费时间。第三个是where标签里AND/OR前缀的处理。MyBatis 的where会自动去掉第一个多余的AND或OR但如果你在where外面拼了 SQL 片段或者用了trim但配置suffixOverrides不对就会出现WHERE AND status 1这种语法错误。建议无脑用where包住所有if条件这是最省心的方式。再补充一个和 XML 特字符相关的查询排序时if testorderBy priceORDER BY custom_price ASC/if这种场景没有特殊符号没问题但涉及、比较时必须转义或者用![CDATA[ ]]包住。尤其是BETWEEN ? AND ?这种直接写会被 XML 解析报错。5.2 缓存引发的数据不一致MyBatis 的缓存是面试高频题也是实际项目的高频坑。一级缓存是SqlSession级别的同一个 SqlSession 内执行相同 SQL 会复用缓存结果。在 Spring 整合环境下SqlSession 的生命周期由 Spring 管理一般请求结束就关闭所以一级缓存影响有限。二级缓存是namespace级别的跨 SqlSession 共享。问题出在如果两个 Mapper 的 namespace 操作同一张表而缓存没有配置关联就会出现A Mapper 更新了表数据B Mapper 的查询还返回旧缓存的脏数据。这个系统里t_order被OrderMapper和OrderAssignMapper都操作开启二级缓存就中招过定制师派单后更新了订单状态但订单列表查询走的OrderMapper二级缓存返回的还是旧状态。最终我把cacheEnabled设为 false在这个体量的项目里数据库查询性能完全够没必要为了缓存去承担一致性问题。如果你确实要开二级缓存我的建议是只给数据基本不变化的基础表开比如t_designer所有涉及订单、库存这类频繁更新的表一律不开。另外任何insert/update/delete默认都会清空对应 namespace 的二级缓存但跨 namespace 的失效做不到这个要记清楚。5.3 MySQL 连接与字符集问题MySQL 这块的坑主要集中在安装配置和连接参数上。热词里好几个都是问我MySQL 8.0 安装配置教程mysql ssl 连接错误这些都是真实高频问题。连接 MySQL 8.0 时JDBC 驱动要用com.mysql.cj.jdbc.Driver而且 8.0 默认的认证插件是caching_sha2_password如果你的驱动包版本不够新会报Unable to load authentication plugin caching_sha2_password。这个系统因为要兼容客户环境我统一把数据库用户改回mysql_native_password认证方式同时在连接 URL 上带上useSSLfalse参数避免 SSL 握手带来的额外开销和连接报错。完整 JDBC URL 参考jdbc:mysql://localhost:3306/travel_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueserverTimezone不加会报时区错误因为 MySQL 8.0 默认时区和 JDBC 驱动不一致。allowPublicKeyRetrievaltrue是配合caching_sha2_password用的如果你改成了mysql_native_password可以不加。字符集的坑更隐蔽数据库表字段是utf8但utf8在 MySQL 里最多 3 字节存不了 Emoji 和生僻字。用户填的出行需求描述里如果带 Emoji插入直接报Incorrect string value数据丢失还可能引起事务回滚。我的方案是建库建表统一用utf8mb4排序规则用utf8mb4_general_ci这个能从根上解决。MySQL 事务和锁的问题在订单并发场景也遇到过。SELECT ... FOR UPDATE在事务里加的是行级排他锁如果订单详情页先查询再更新应该在事务内用FOR UPDATE锁定订单行防止两个管理员同时操作同一订单。这里要注意锁的粒度FOR UPDATE必须走索引否则 InnoDB 会锁全表。我排查过一次线上卡顿就是因为WHERE条件没走索引一个简单的更新把整张订单表锁了所有下单请求全部阻塞。所以在写这类语句前我都会先EXPLAIN看一下是否命中索引。6. 性能优化与经验心得6.1 N1 查询问题与抓取策略订单列表页要显示订单所属用户、对应的需求单、绑定的线路信息。如果我在OrderMapper里只查订单表然后在 Service 层循环查用户和线路就是典型的 N1 查询查一次订单列表 10 条然后循环 10 次各查一次用户再加 10 次各查一次线路一共 21 次 SQL。数据量小的时候没感觉几百条订单时页面会明显变慢。MyBatis 解决 N1 的方式是resultMap里的association和collection配合嵌套查询或嵌套结果映射。我用的方式是嵌套结果映射一条关联 SQL 用LEFT JOIN把订单、用户、线路一次性查出来用resultMap把结果映射到多层对象resultMap idOrderDetailMap typeOrder id propertyorderId columnorder_id/ result propertyorderNo columnorder_no/ association propertyuser javaTypeUser id propertyuserId columnuser_id/ result propertyusername columnusername/ /association association propertyroute javaTypeRoute id propertyrouteId columnroute_id/ result propertytitle columntitle/ /association /resultMap注意如果对fetchTypelazy使用不当又会产生隐藏的 N1某个属性用到时才发 SQL循环访问该属性就触发多次查询。小项目直接关闭懒加载或者用嵌套结果映射的 JOIN 方式反而更可控。6.2 分页、索引与慢 SQL 排查分页我用的手写LIMIT方式因为项目本身没有引入 PageHelper 依赖。分页查询的写法是LIMIT #{offset}, #{pageSize}offset (pageNum - 1) * pageSize。这里有个性能细节深分页时LIMIT 100000, 20会扫描前面 10 万条数据再丢弃非常慢。我当时订单列表翻到后面几页时接口响应明显变慢排查后确认是这个原因。优化方案是子查询分页——先查主键再回表查详情SELECT o.* FROM t_order o JOIN (SELECT order_id FROM t_order ORDER BY order_id DESC LIMIT 100000, 20) t ON o.order_id t.order_id这个写法让 MySQL 在子查询里只扫主键索引比全行扫描快一个数量级。慢 SQL 排查我用的是最朴素的方案打开 MySQL 的慢查询日志设置long_query_time 1把所有超过 1 秒的 SQL 记录下来然后逐条EXPLAIN分析。这个项目里出现过的最典型慢查询是订单统计 SQLSELECT user_id, COUNT(*) FROM t_order GROUP BY user_id没有索引全表扫描几万条数据。优化方式是给user_id建索引GROUP BY就可以走索引避免文件排序。6.3 工程化小技巧与个人体会最后分享几个项目中沉淀下来的工程化小技巧虽然不是核心业务但能显著提升代码质量和排障效率。统一返回结构是必须的。所有接口返回Result对象包含code、message、data三个字段。前端无论请求哪个接口处理逻辑统一code 0时读data否则弹message。这样前后端联调时不需要为每个接口定制解析逻辑。全局异常处理用ControllerAdvice加ExceptionHandler。Controller 里不写 try-catch业务异常统一抛BusinessException全局处理器负责把异常转成对应的Result。日志方面我用 SLF4J Logback在全局异常处理器里记录 error 级别的堆栈同时记录请求参数和用户 ID方便事后定位是哪个用户、哪个参数触发了异常。开发环境建议配置spring-mvc.xml里的静态资源映射。DispatcherServlet 的/会拦截所有请求包括 CSS、JS、图片必须在配置里声明mvc:resources mapping/static/** location/static//否则页面样式全部加载不出来。这个坑想必每个做 Spring MVC 的人都踩过我列在这里权当提醒。数据库连接池参数也要留个心眼。Druid 的validationQuery设成SELECT 1连接空闲回收minEvictableIdleTimeMillis设置 5 分钟避免 MySQL 的wait_timeout默认 8 小时把空闲连接断开后连接池还在往一个过期连接上发 SQL导致偶尔报Communications link failure。这个问题在系统闲置一晚后第二天首次访问时最容易出现。这个项目前后做了一个多月整体做下来最大的感受是用 Spring MVC MyBatis 写业务确实比 Spring Boot JPA 啰嗦但每一层都是透明的出了问题你能顺着请求链路一层层找到底。尤其是 MyBatis 的动态 SQL 和缓存机制如果你只是在 Spring Boot 里无脑写注解永远不会理解为什么加了二级缓存之后数据对不上。最后给正在做类似项目的同学两个建议一是先把表结构设计烂熟于心再动手写代码我在这套系统上改表结构的成本有一大半都是因为前期没把需求和字段关系想清楚二是别急着引入各种高大上的组件把事务、连接池、动态 SQL 这些基本功吃透这样的项目才真正有含金量。把这个项目完整跑通你对 JavaWeb 的理解会上一个台阶。
返回列表