ARTICLE DETAIL

资讯详情

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

Mybatis实战:基于表白墙案例掌握动态SQL、缓存与拦截器

Mybatis实战:基于表白墙案例掌握动态SQL、缓存与拦截器 不少人在学Mybatis的时候都有过一个共同的困惑文档看懂了面试题背熟了但一到手写真实项目还是不知道怎么组织Mapper、怎么选缓存、怎么处理那些奇奇怪怪的参数报错。拿“表白墙留言板”做Mybatis综合案例是我自己带新人时最常用也最推荐的一条路径。这个项目的业务场景足够轻不需要理解复杂业务域但“发布留言、列表分页、标签筛选、关键词搜索、点赞置顶、软删除”这一套下来几乎能把Mybatis日常开发里80%的高频知识点全部串起来。这篇文章就按照我完整实现一遍的流程来写从建库建表到最后的排查经验每一步都会讲清楚“为什么这么做”和“这里有什么坑”代码直接可以照着敲。1. 表白墙这个案例为什么值得认真做一遍很多初学者对Mybatis的认知停留在“写个接口、配个XML、执行SQL”觉得CRUD都是复制粘贴没什么技术含量。但真实开发中一个看似简单的留言板背后其实藏着大量平时文档里不会写清楚的细节。1.1 一个小功能背后的Mybatis知识点全景我们先不急着写代码先盘一下做一个可用的表白墙需要哪些能力用户提交一条表白后端要把昵称、内容、标签、时间写入数据库这涉及insert的返回值处理和主键回填。列表页要分页展示老数据要能翻页看这涉及limit分页或者分页插件。用户可以按“悄悄话”“道歉”“表白”等标签筛选也可以在搜索框输入关键词这涉及动态SQL。每条留言可以点赞、可以置顶操作时希望只更新有变化的字段这涉及动态set。记录创建时间和更新时间不能靠业务层每次手动设置这属于拦截器的典型场景。数据量变大之后需要考虑列表接口的缓存策略这又牵扯到Mybatis一级缓存、二级缓存的工作机制。把这一串列出来你就会发现表白墙虽然业务简单但它是一个天然的Mybatis“练兵场”。同样一个功能初级写法是把SQL写死一个个接口硬编码而工程化的写法是先把动态SQL、结果映射、分页、填充、缓存这些东西都想清楚再动手。1.2 项目到底做成什么样需求清单与验收标准开始编码之前我建议先把自己的验收标准定下来否则写着写着就会变成“能跑就行”。我的做法是先列一个最小功能清单功能说明涉及的Mybatis知识点发布留言昵称可选默认匿名内容必填insert主键回填列表分页每页10条按时间倒序limit、PageHelper标签筛选支持按标签过滤动态SQL where/if关键词搜索内容模糊匹配like拼接与注入防护点赞点赞数1update自增置顶置顶留言排最前update动态字段排序查询软删除不物理删除标记is_deletedupdate逻辑删除时间自动填充创建/更新时间自动维护拦截器我还会在项目里加上批量导入数据的管理功能专门用来做Mybatis批量写操作的压测对比。这块很多人会问“实际开发时这种情况多吗”我的回答是多而且非常常见。批量插入、批量更新不是面试造火箭像后台批量导入用户、批量上下架商品、批量同步订单状态都是日常需求。2. 建库建表和工程结构先把地基打扎实写SQL之前先设计表这是个老生常谈但依然有人偷懒的环节。表白墙的表结构并不复杂但字段设计直接决定后面SQL写起来痛不痛快。2.1 表结构设计一张留言表怎样才能不返工第一次设计留言表时我犯过一个典型错误只设计了id、nickname、content、create_time四个字段结果后面要加标签、点赞、置顶、软删除在测试环境里反复alter table非常被动。因为字段不是一次加完索引也没有统一规划。一个相对完整的留言表我建议这样设计CREATE TABLE message ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, nickname VARCHAR(32) NOT NULL DEFAULT 匿名用户 COMMENT 昵称, content VARCHAR(500) NOT NULL COMMENT 留言内容, tag VARCHAR(20) DEFAULT 表白 COMMENT 分类标签, like_count INT NOT NULL DEFAULT 0 COMMENT 点赞数, is_top TINYINT NOT NULL DEFAULT 0 COMMENT 是否置顶 0否 1是, is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT 是否软删除 0否 1是, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_created_at (created_at), KEY idx_tag (tag), KEY idx_is_top (is_top) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT表白墙留言表;这里有几个设计要点content用utf8mb4字符集因为昵称和内容里可能出现Emoji表情utf8存不了。is_deleted软删除字段是我后面特别补的实际上线时用户删除留言不应该直接物理删掉既能做操作审计也能防止误删无法恢复。索引不需要建太多但查询条件里最常用的created_at、tag、is_top要覆盖到。像like_count这种高频更新字段建索引意义不大反而增加维护成本。2.2 工程分层controller-service-mapper的边界这个案例我推荐用Spring Boot mybatis-spring-boot-starter来做因为现在绝大多数公司都是这么集成Mybatis的。spring和mybatis集成也是面试里喜欢问的点本质上就是让Spring把SqlSessionFactory、MapperScannerConfigurer这些Bean管理起来让Mybatis的Mapper接口能被注入到Service层。工程结构我习惯这样分src/main/java ├── com.example.wall │ ├── WallApplication.java │ ├── controller │ │ └── MessageController.java │ ├── service │ │ ├── MessageService.java │ │ └── impl │ │ └── MessageServiceImpl.java │ ├── mapper │ │ └── MessageMapper.java │ ├── entity │ │ └── Message.java │ ├── dto │ │ ├── MessageQueryDTO.java │ │ └── MessageCreateDTO.java │ ├── common │ │ └── PageResult.java │ └── config │ └── MybatisConfig.java └── resources ├── application.yml └── mapper └── MessageMapper.xml很多人学框架时喜欢把Controller里直接写Mapper这个习惯在项目里要戒掉。Controller只负责接收参数和返回结果Service负责业务逻辑Mapper只跟数据库打交道。尤其表白墙后期会加缓存、加消息队列如果业务逻辑散落在Controller里改起来就是一场灾难。2.3 环境准备与配置最容易忽略的版本问题Spring Boot选型上我建议用2.7.x配合mybatis-spring-boot-starter 2.3.x。为什么要强调版本因为Mybatis官方starter在3.x之后把包名从org.mybatis.spring.boot迁移到了org.mybatis.spring.boot3如果Spring Boot版本和starter版本不匹配启动时会直接报ClassNotFoundException这个坑我帮别人排查过很多次。核心配置如下spring: datasource: url: jdbc:mysql://localhost:3306/wall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.wall.entity注意mapper-locations一定要配置成classpath:mapper/*.xml否则Spring Boot扫描不到XML文件运行时会报“Invalid bound statement (not found)”。这个报错信息我见过太多次了90%的原因就是Mapper接口和XML的namespace不对应或者XML没有被打包进classpath。3. 核心功能逐个落地从发布到查询的编码细节地基打好之后就可以开始写核心功能了。这一部分我建议不要跳着看因为每个功能背后都有一个对应的Mybatis机制理解了机制后面遇到类似需求才能举一反三。3.1 发布留言insert的返回值和参数传递是两回事发布留言的Mapper方法很多人写完之后关注点全在“SQL有没有插入成功”却忽略了一个经典面试题insert方法的返回值到底是什么答案是返回的是影响行数而不是自增主键。要想拿到刚插入数据的id必须在XML里配置useGeneratedKeys和keyPropertyinsert idinsertMessage parameterTypecom.example.wall.entity.Message useGeneratedKeystrue keyPropertyid insert into message (nickname, content, tag) values (#{nickname}, #{content}, #{tag}) /insertkeyProperty的值是实体类里的属性名不是数据库字段名。useGeneratedKeystrue让JDBC把数据库生成的自增主键回填到Java对象的id属性上。这样一下发完留言代码里立刻message.getId()就能拿到主键便于后续生成分享链接或者关联附件。发布接口的Service实现我一般会这样处理Override public Long create(MessageCreateDTO dto) { Message message new Message(); message.setNickname(StringUtils.hasText(dto.getNickname()) ? dto.getNickname() : 匿名用户); message.setContent(dto.getContent()); message.setTag(dto.getTag()); messageMapper.insertMessage(message); return message.getId(); }这个简单功能里还藏着一个传参细节如果Mapper方法只有一个参数XML里用#{任意名字}都能取到但一旦方法有多个参数比如ListMessage selectByCondition(String nickname, String tag, Integer page, Integer size)不处理就会报错。解决办法是必须加Param注解ListMessage selectByCondition(Param(tag) String tag, Param(keyword) String keyword, Param(offset) int offset, Param(size) int size);加了Param之后XML里才能用#{tag}、#{keyword}不加的话Mybatis只能通过param1、param2或者arg0、arg1去引用代码阅读性极差也极其容易出Bug。3.2 留言列表与分页手写Limit还是引入PageHelper表白墙的列表页必做分页。分页在Mybatis里有两种主流写法。第一种手写limit。select idselectByCondition resultTypecom.example.wall.entity.Message select id, nickname, content, tag, like_count, is_top, created_at from message where is_deleted 0 if testtag ! null and tag ! and tag #{tag} /if if testkeyword ! null and keyword ! and content like concat(%, #{keyword}, %) /if order by is_top desc, created_at desc limit #{offset}, #{size} /select这种方式的好处是直观、可控而且方便做深度分页优化。前端传page和size时Service层算一下offset (page - 1) * size就可以。第二种用PageHelper插件。它能自动帮你在执行查询之前拦截SQL加上limit并且查总条数。用法是PageHelper.startPage(page, size); ListMessage list messageMapper.selectByCondition(...); PageInfoMessage pageInfo new PageInfo(list);PageHelper的用法简单但它有一个需要特别注意的点PageHelper.startPage只能对紧接着的下一条查询生效。如果有人习惯把startPage和查询之间隔了几行其他代码或者调用了别的Mapper方法分页就会跑偏到别的SQL上。我在代码评审里看到过好几次这种情况。至于怎么选我的经验是如果你用的是Spring Boot且项目里已经有比较统一的Mybatis配置PageHelper很方便如果团队追求SQL可读性、对分页有性能和索引方面的定制要求手写limit更靠谱。表白墙这个案例为了把SQL细节讲清楚我后面都用手写limit的方式。3.3 标签筛选和关键词搜索动态SQL的where、if、choose列表查询是动态SQL最典型的应用场景。用户在页面上的筛选条件是可选的这时候最忌讳写三条不同SQL来应对不同情况正确做法是组合条件。上面那段selectByCondition里已经用到了if和where。这里有一个细节我要单独拿出来说where标签会自动干掉拼接SQL里第一个多余的AND或OR所以标签内每个子条件的and可以放心写在前面。那mybatis switch在哪里用呢很多人看Mybatis文档会发现它并没有switch标签但有一个等价的choose。比如前端传一个排序字段sortlatest按最新、sorthot按热度我们就可以这样写choose when testsort hot order by like_count desc, created_at desc /when otherwise order by created_at desc /otherwise /choosechoose是if ... else if ... else的逻辑跟Java里的switch一个思路只是在XML里叫法不同。这个功能我建议一定亲手写一遍因为实际项目里“同一张表、不同排序规则、不同查询条件”这种需求几乎是标配。关键词搜索这里还要强调一个安全点。模糊查询很多人一开始会这样写if testkeyword ! null and keyword ! and content like %${keyword}% /if千万不要这么干。${}是字符串拼接用户一旦在关键字里输入 or 11之类的内容SQL语义就会被破坏这就是经典注入漏洞。正确写法是用concat拼参数占位and content like concat(%, #{keyword}, %)之前我看到一个开发为了图方便把表名和排序字段也拿${}拼接结果被扫描出高危漏洞整个服务紧急下线整改。记住表名、列名、排序字段这类非字符串元素如果必须动态传入一定要做白名单校验字符串值一律用#{}。3.4 点赞和置顶动态update与自增更新点赞功能如果先查一次原点赞数在内存里加1再写回高并发下一定会丢数据。正确做法是直接在SQL层做自增update idincrementLikeCount update message set like_count like_count 1 where id #{id} and is_deleted 0 /update这样每次更新都是原子操作不需要加分布式锁。同理置顶功能也不应该把所有字段都更新一遍而是用动态setupdate idupdateMessage update message set if testcontent ! null and content ! content #{content}, /if if testisTop ! null is_top #{isTop}, /if /set where id #{id} /updateset标签会自动处理末尾的逗号避免因为某个条件不满足而留下一个悬空的,导致SQL语法报错。这个“只更新非空字段”的写法在真实项目中比全字段覆盖更新要安全得多。因为调用方如果只传了isTopupdateMessage就不会把content置空。这里我再补一个小技巧如果希望一条SQL同时完成置顶和更新时间刷新我们可以直接在set里加上updated_at now()不用跑到Java代码里再setUpdatedAt少一次不必要的传参。4. 性能与工程化缓存、批量操作和拦截器实战功能写完只是第一步。既然是综合案例就一定要把工程里真正会遇到的性能问题和代码规范问题放进来。4.1 Mybatis一级缓存和二级缓存表白墙场景下怎么取舍关于Mybatis缓存网上很多文章写得云里雾里我尝试用最简单的话说明白。一级缓存是SqlSession级别的。默认开启同一个SqlSession里执行两条相同的SQL第二次会直接返回缓存对象不查询数据库。但在Spring Boot集成Mybatis之后每次Mapper方法调用基本都会新建并关闭一个SqlSession所以一级缓存在“无事务方法里”几乎感知不到真正能感知到的场景是一个方法加了Transactional方法里多次调用同一条查询第二次不会走到数据库。二级缓存是namespace级别也就是一个Mapper接口一个缓存区。默认关闭需要手动在XML里加cache/开启。它能跨SqlSession生效但我要明确告诉你这个案例里我强烈不建议开二级缓存。原因有三个。第一二级缓存默认是本地缓存项目一旦部署多个实例实例之间数据不一致用户A点赞之后用户B在另一台机器上刷新可能还是旧数据。第二缓存的失效时机依赖增删改操作。Mybatis执行update或delete时会清空对应namespace的缓存但如果你有联表查询、或者多个Mapper操作同一张表很容易出现缓存没有及时刷新导致脏读。第三现在互联网项目基本都会引入Redis既然要上缓存为什么不把缓存放到统一的外部存储里而要分散在每台机器本地呢所以真正的生产做法是如果列表读取量实在大在Service层按业务维度加Redis缓存手动控制过期时间和缓存更新Mybatis的二级缓存基本不用。这一段的底层机制如果有兴趣可以去看Mybatis源码里CachingExecutor和PerpetualCache的实现看完就知道为什么它适合缓存读多写少、单机部署、对一致性要求低的场景而留言板显然不具备这些特性。4.2 批量操作留言数据批量插入到底值不值得用我在项目里加了一个“批量导入历史留言”的接口专门用来演示Mybatis批量写操作。为什么要单拎出来讲因为“使用mybatis进行批量写操作实际开发时这种情况多吗”这个问题几乎每次技术交流都会被问到。答案是多。不管你是做电商后台的批量改价还是做运营系统的批量导入批量写都是避不开的。批量插入最常见的写法是用foreach拼接多条valuesinsert idbatchInsert insert into message (nickname, content, tag) values foreach collectionlist itemitem separator, (#{item.nickname}, #{item.content}, #{item.tag}) /foreach /insertMapper接口对应写成int batchInsert(Param(list) ListMessage list);这里有个性能细节不是批次数越大越好。MySQL对单条SQL的长度有max_allowed_packet限制默认通常是4M或64M。如果一次性拼接几万条数据SQL包太大不仅可能触发限制还会让数据库解析SQL的时间变长甚至锁表时间过长。实践下来单批控制在500条左右比较稳比如要插入1万条就分20批提交。另一个经验是批量插入时JDBC连接字符串可以加上rewriteBatchedStatementstrue这样驱动会把多条insert合并成一条多值SQL性能提升明显。这个参数属于MySQL JDBC连接参数平时不留意的话写了批量代码也可能没有享受到真正的批量效果。4.3 用拦截器统一填充创建时间少写十行重复代码每个表都有created_at和updated_at字段。在早期项目中我见过大家一个个手动setCreateTime(new Date())代码里到处都是重复操作。后来我引入了Mybatis拦截器统一处理。Mybatis拦截器可以拦截四大核心对象Executor、StatementHandler、ParameterHandler、ResultSetHandler。我们做自动填充拦截的是Executor的update方法。核心代码如下Intercepts({ Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) public class AutoFillInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; if (parameter null) { return invocation.proceed(); } // 根据SQL类型决定填充哪些时间 SqlCommandType sqlCommandType ms.getSqlCommandType(); LocalDateTime now LocalDateTime.now(); if (sqlCommandType SqlCommandType.INSERT) { setFieldValue(parameter, createdAt, now); setFieldValue(parameter, updatedAt, now); } else if (sqlCommandType SqlCommandType.UPDATE) { setFieldValue(parameter, updatedAt, now); } return invocation.proceed(); } }注意一个细节拦截器里拿到Object parameter后如果方法的参数是一个实体类可以直接反射设置但如果有多个参数参数会被包成ParamMap这时候就需要循环遍历map里的每个值找到实体类再填充。这个不做兼容的话拦截器会间歇性失效而且很难排查。注册配置类Configuration public class MybatisConfig { Bean public AutoFillInterceptor autoFillInterceptor() { return new AutoFillInterceptor(); } }有了拦截器之后业务代码里再也不用手动写创建时间代码整洁度提升明显。这也是面试里问“Mybatis拦截器能干什么”时一个很加分的实战回答。4.4 把SQL日志打印出来排查效率翻倍的配置我在看别人项目的时候发现有不少人还在用“猜测法”排查SQL问题程序跑出奇怪的结果就在代码里到处打日志而不是先看Mybatis真正执行了什么SQL。Mybatis打印SQL的方法很简单在application.yml里加一行mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl启动项目之后控制台会打印每一条实际执行的SQL、参数和返回结果数。比如 Preparing: select id, nickname, content, tag, like_count, is_top, created_at from message where is_deleted 0 and tag ? order by is_top desc, created_at desc limit ?, ? Parameters: 表白(String), 0(Integer), 10(Integer) Total: 10我见过太多“为什么查不到数据”的问题把SQL一打印马上就能看到是参数传错了还是条件拼接错了。如果你用的是IDEA还可以装一个MyBatis Log Plugin插件。它的作用是把Mybatis底层被?占位的SQL还原成完整SQL然后可以直接拿去数据库工具里执行。这样排查效率会再上一个台阶。有一点要注意新版IDEA对插件市场有兼容性限制插件装不上时不要死磕直接用StdOutImpl就够用了。mybatis配置打印这块还有一个隐藏价值它能把ResultMap的映射问题暴露得很清楚。如果查询结果的某一列一直是null看SQL返回的列名和实体属性是否对得上往往比猜代码快得多。5. 实战翻车现场这些坑我建议你提前避开这一节我写的都是实际开发中高频出现、而且能难住不少人的问题。把这些问题彻底搞懂很多Mybatis面试题也能迎刃而解。5.1 查询结果一直为空问题出在参数传递新手最常见的一个报错是org.apache.ibatis.binding.BindingException: Parameter tag not found. Available parameters are [arg1, arg0, param1, param2]这个报错的原因就是Mapper方法有多个参数但没有加Param注解。Mybatis把参数默认命名为arg0、arg1JDK8以上还会变成param1、param2XML里引用不到有意义的名称自然找不到。解决方式前面已经说了给每个参数加Param。这个不是“规范问题”而是能不能跑起来的问题。5.2#{}和${}混用导致SQL注入和格式错误以我的经验很多人在初学阶段写SQL传参时会困惑“为什么我用#{tag}不行换成${tag}就行了”。区别在于#{}是预编译占位符最终SQL中是?值由JDBC的PreparedStatement设置安全。${}是字符串拼接直接把值拼接到SQL语句中不安全而且如果值是字符串可能还需要手动加单引号。记住一句话能用#{}的地方坚决不用${}必须用${}的地方比如动态表名、排序字段要做白名单校验。Mybatis面试题里几乎每个面试官都爱问这个区别这不仅仅是背诵知识点它直接关系到线上数据安全。5.3 LocalDateTime和MySQL驱动版本的映射问题留言表里存的是DATETIMEJava实体类里我用LocalDateTime接收。版本配对不好时会报类似这样的错Caused by: java.sql.SQLException: Cannot convert value 2024-06-01 12:00:00 from column 7 to TIMESTAMP.这个问题绝大多数情况是mysql-connector-java版本太老导致的。5.x版本的驱动对JSR-310时间类型支持不够升级驱动到8.0.33以上就能解决。同时在JDBC连接串里配置serverTimezoneAsia/Shanghai否则直接用默认时区可能会跟数据库时间差8个小时。这个8小时的坑不知道坑过多少人了。5.4 修改数据后查到旧数据缓存刷新时机一次典型的“灵异事件”是列表页正常用户点了点赞页面刷新后点赞数还是老样子。第一反应是更新语句没生效但到数据库里看数据已经变了。这时候就要想一下缓存。如果你用的Mybatis二级缓存或者项目里加了Redis缓存就很容易出现缓存没有在写操作后刷新。二级缓存在update时会自动清理当前namespace但如果你的点赞操作改的是message表而查询操作走的是另一个Mapper的namespace缓存清理就不会触发。还有一个隐蔽的场景在同一个事务里先查了留言然后更新点赞数之后再查一次留言。因为一级缓存还在第二次查询可能直接返回了第一次查询的对象。此时不要惊讶这其实是设计上的固有问题。解决办法是事务内不要做这种“先读、再写、再读”的多余操作或者对查询使用Transactional(propagation Propagation.NOT_SUPPORTED)让它在事务外执行。6. 案例做完之后还可以往哪里深入代码跑通了功能都能用了是不是就结束了远没有。真正想从“会写”到“懂原理”我建议给自己再加几个挑战。6.1 从留言板到评论系统表结构要做哪些演进表白墙本质上是一张单表留言板。如果把需求升级成“每一层留言都能回复别人”你会发现单表结构马上就不够用了。要么增加parent_id字段用邻接表模型表达层级要么把回复拆成单独一张表。这个演进过程很像真实项目里从MVP到成熟产品的变化。顺着这个方向我们还能继续往深处思考楼层号怎么生成删除父留言时子留言怎么处理点赞数据量大了是继续在message表上累加还是单独抽一张点赞流水表这些问题比单纯写CRUD有价值得多。6.2 从单机到集群索引、缓存与读写分离表白墙如果再往前走一步会碰到更实际的性能问题。数据量大之后like_count的自增更新会竞争同一行记录要不要改为异步计数首页热门的置顶列表要不要做成短缓存比如5秒过期避免每次都查数据库搜索关键词涉及like %keyword%这种模糊查询在数据量大时很难走索引那是否要引入全文检索这些问题没有一个标准答案但它们才是“综合案例”真正的延伸方向。基础代码只是敲门砖把这些环节想明白你才算真正把Mybatis乃至整个后端技术栈用得有一点心得。回到我自己的体会这个表白墙案例我给很多新人推荐过也带人完整敲过不止一遍。每次做都会发现新的细节比如拦截器兼容多参数、批量插入的SQL大小限制、日志打印对排错帮助到底有多大。如果你能把这个项目里的动态SQL、主键回填、批量操作、拦截器、缓存取舍、日志排查这六件事讲清楚不管是写业务代码还是应对面试心里都会踏实很多。最后再分享一个小习惯项目做完之后把每个技术点整理成一个简短的Markdown笔记记录自己踩过的坑和验证过的结论。下次遇到类似问题翻自己的笔记比网上搜答案快得多也更靠谱。
返回列表