MyBatis in Practice: Mappers, Dynamic SQL and Paging
First, bust a myth: MyBatis does not write SQL for you. It does one humble job — you supply the SQL; it carries your method arguments into that SQL and carries the returned rows back into Java objects. Every bit of repetitive labour in between (getting a connection, pre-compiling, setXxx binding, iterating the ResultSet, assigning field by field, wrapping exceptions, returning the connection) is taken off your hands. So learning MyBatis really comes down to two things: where the SQL lives, and how to make it line up with your Java objects.
Six terms, one line each (used throughout):
- Mapper interface: the Java interface where you only declare methods, never implement them, e.g.
UserMapper.findById(Long id). It is the order slip, not the kitchen - XML mapping file: a list of SQL statements under a
namespace; thenamespacemust be the interface's fully-qualified name and theidmust equal the method name — those two threads stitch the pair together - SqlSession: MyBatis's execution entry point, think of it as the counter clerk for one session;
selectOne/insert/updateall pass through it - Executor: the dispatcher behind SqlSession; it decides whether to consult a cache, reuse statements, or batch
- resultMap / resultType: the two ways to pour results back into objects. Use
resultTypewhen columns and fields already agree; write aresultMapwhen they do not (or when associations are involved) #{}versus${}: the first is a parameter (pre-compiled, safe); the second is string concatenation (it can rewrite the SQL structure, dangerous). This is the single most important dividing line in the article
MyBatis is an interpreter. The client (your Java code) speaks Chinese; the counter (the database) only listens to English. The interpreter's duty is to haul information between them and never invent what the client asked for. The mapping is neat: the Mapper interface is the slip you hand over saying "translate this"; the SQL in the XML is the draft the interpreter prepared; #{} is a blank left in the draft that someone fills with a number at the end (nobody can alter the sentence pattern); ${} is copying the client's raw words straight into the sentence (if they slip in "and drop that table while you're at it", the sentence really does change); resultMap is writing their answer back into your form line by line; and leaving the camelCase flag off is the interpreter translating user_name as "用户名" while your entity files it under userName — so that box stays empty forever. The interpreter hauls; it does not think for you. Hold onto that line and you sidestep eight tenths of MyBatis traps.

That tag-family tree is the map of this article: nearly every assembly job you meet in daily work lands in one of these six branches. Section 5 gives copy-ready fragments for each.
After this article you should be able to answer three questions:
- I wrote only an interface, not a single implementation class — so who actually executes
userMapper.findById(1L)? - When
Invalid bound statement (not found)appears, which three threads should I pull? - Why is
<if test="keyword != null">not enough? What does an empty string turn the SQL into?
Feel how easily "mapping" goes wrong first — flip the two switches and the console output on the right changes instantly:
===> Preparing: SELECT id, user_name, status FROM t_user WHERE id = ?===> Parameters: 1(Long)<=== Row: 1 | alice | 1User{id=1, userName='alice', status=1}# column user_name reaches userName via the camel rule — no mapping code at all
of the four cells, only the bottom-right "half filled, half empty" one is hard to spot yourself — it neither errors nor comes back blank. Build the habit real projects use: compare the logged <=== Row line with what your object prints. Mismatch means suspect the mapping first, not the SQL.
For the data-access layer there is one soul-searching question: should the framework write your SQL? Hand everything to the framework (JPA/Hibernate) and CRUD is lightning fast — until you hit a complex report, a multi-table join, or a performance tuning session, and then you fight the SQL the framework generates. Write everything by hand (JdbcTemplate) and you own the SQL — but shuffling ResultSet rows into objects wears you down.
MyBatis answers with "semi-automatic": it will not write your SQL, it only stitches the tedious gap between SQL and Java objects. You provide the SQL; it handles pre-compilation, parameter binding, result mapping, caching and transaction integration.

| Option | SQL control | Dev speed | Learning cost | Typical fit |
|---|---|---|---|---|
| JdbcTemplate | Maximum, all hand-written | Low (lots of boilerplate) | Low | Reports, batch jobs, tiny tools |
| MyBatis | High (you write SQL, fully controllable) | Medium-high | Medium | Complex queries, performance-sensitive, SQL you want to edit |
| JPA / Hibernate | Low (framework generates SQL) | High (near-zero CRUD code) | High | Clear domain model, mostly CRUD |
there is no absolute winner, only fit. When SQL is complex, tuning matters and the team knows SQL, MyBatis feels natural. When the model is stable and CRUD dominates, JPA is more productive. Let's first see what each layer on the MyBatis chain does, then wire it up.
Spring Boot does not bundle MyBatis; you need the official starter. Three pieces and you rarely touch config again:
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> <!-- for Spring Boot 3.x / JDK 17+ --></dependency><dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope></dependency>Then application.yml. These three keys are what beginners most often miss — and they produce the weirdest bugs:
mybatis: mapper-locations: classpath:mapper/*.xml # where the XML lives type-aliases-package: com.example.demo.entity # entity package, so XML can omit FQNs configuration: map-underscore-to-camel-case: true # user_name -> userName, must be on log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # print SQL to console# optional: the PageHelper paging plugin, see section 7Add the scanning annotation on the application class so the container knows where mappers live:
@SpringBootApplication@MapperScan("com.example.demo.mapper") // scan them all at once, no @Mapper per interfacepublic class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); }}mapper-locationsis empty by default; XML must sit in the right place or you getInvalid bound statement (not found)- with
map-underscore-to-camel-case: true, DBuser_namemaps touserNameautomatically — no@Resultsneeded @MapperScanand per-interface@Mapperare alternatives; the former is tidier
Trap: XML placed under a package inside src/main/java is not copied into target/classes by default. Put XML in src/main/resources/mapper/, or add a resources block to the pom — otherwise you are guaranteed an Invalid bound statement error.
Dependencies and configuration each have three or four viable combinations — do not memorise them, generate them. Start with the pom: what do MyBatis + MySQL alone give you, how many lines does a test class lose once H2 and Test are ticked, and where does each scope land:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
<!-- 第三方 starter:必须写版本 -->
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>Then the yml: datasource alone is the minimum that lets a mapper run; logging is what puts ===> Preparing on screen (Sections 8 and 11 both depend on it); profile shows how dev and prod databases split across files:
server:
port: 8080
spring:
application:
name: demo-service
datasource:
url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${DB_USER:root} # ${} 占位符:环境变量优先,冒号后是默认值
password: ${DB_PASS:}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1740000 # 必须小于 MySQL 的 wait_timeout
pool-name: beeHikari
logging:
level:
root: INFO
com.example.demoservice: DEBUG
org.springframework.jdbc.core.JdbcTemplate: DEBUG # 打 SQL 与参数
file:
name: logs/app.log
logback:
rollingpolicy: { max-file-size: 50MB, max-history: 14 }
those two artefacts together answer everything in this section. Keep one self-check habit: for every box you tick, ask "if I remove it, which log line or which error disappears" — far more useful than memorising property names.
First the trio of entity, interface and XML. This is the skeleton for everything that follows:
// Entity: camelCase fields; the DB column may differ — underscore mapping or resultMap bridges itpublic class User { private Long id; private String userName; // DB column user_name private Integer status; // getters / setters omitted}public interface UserMapper { User findById(Long id); List<User> findByStatus(@Param("status") Integer status); int insert(User user);}<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.demo.mapper.UserMapper"> <select id="findById" resultType="User"> SELECT id, user_name, status FROM t_user WHERE id = #{id} </select> <select id="findByStatus" resultType="User"> SELECT id, user_name, status FROM t_user WHERE status = #{status} </select> <insert id="insert" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_user (user_name, status) VALUES (#{userName}, #{status}) </insert></mapper>
That one covers "how many steps a SQL takes". The animation below answers the question beginners actually have: the interface has no implementation class — so who does the work? Seven hand-offs that introduce all five names at once: MapperProxy, SqlSession, Executor, StatementHandler, ResultSetHandler.

The method name must match the XML id, and namespace must be the interface's fully-qualified name. Miss either and startup fails outright.
Now the heart of this section: #{} versus ${} is not "quoted vs unquoted" — it is "pre-compiled" vs "string-concatenated".
| Form | What happens underneath | Injection-safe | Correct use |
|---|---|---|---|
#{name} | Produces a PreparedStatement ?; the value goes through setXxx | Yes | 99% of all parameter passing |
${name} | The value is concatenated into the SQL string | Risky | Table names / column names / sort fields — structural fragments |
Here is a real injection scene. This one wants to sort by a field from the client:
@Select("SELECT * FROM t_user ORDER BY ${orderBy}")List<User> listOrderBy(@Param("orderBy") String orderBy);If orderBy comes straight from a request parameter, an attacker sends id; DROP TABLE t_user--, and the concatenated SQL becomes ... ORDER BY id; DROP TABLE t_user--. ${} is not pre-compiled; the SQL structure is rewritten and injection happens.
// Safe version: whitelist the sort column, only allow fixed values, then use ${} for structureprivate static final Set<String> ALLOWED = Set.of("id", "user_name", "status", "created_at");public List<User> listOrderBy(String raw) { if (raw == null || !ALLOWED.contains(raw)) { raw = "id"; // illegal input falls back to a safe default } return userMapper.listOrderBy(raw); // raw is now guaranteed to be whitelisted}#{}: a parameterised query — the database treats the value as data, and it can never change the SQL grammar${}: string substitution — the value becomes part of the SQL structure, usable only where parameterisation is impossible (table / column names, sort direction)- One line to remember: use
#{}wherever you can; when${}is unavoidable, whitelist first
Those three lines are the verdict; the animation shows where the road forks. Watch frames ③ and ④ — the SQL shape is already frozen at the PreparedStatement moment, so anything arriving afterwards can only fill a slot and can never rewrite the sentence; ${} did its damage before frame ① by copying the value into the sentence itself:

Then run the experiment yourself. On "injection scene" you see a literal ' OR 1=1 -- swallow the whole WHERE clause; on "when you must concatenate" you see why #{} is helpless in the ORDER BY position — the one place in this section where #{} genuinely cannot be used:
There is only one rule, but where it applies and where it does not is what actually blocks beginners — because whether a position can be parameterised depends on what role that position plays in SQL grammar. Play a round: pick a position on the left, then the syntax it needs on the right; a wrong pair explains itself:
With a single parameter MyBatis takes it directly. With several you need @Param to name them, otherwise you are stuck with the awkward aliases arg0/param1:
// Multiple params need @Param, else the XML only sees #{arg0} #{arg1} or #{param1} #{param2}List<User> search(@Param("keyword") String keyword, @Param("status") Integer status, @Param("offset") int offset, @Param("size") int size);When field and column names diverge, declare a resultMap; for joins use association (one-to-one) and collection (one-to-many):
<resultMap id="OrderWithItems" type="Order"> <id property="id" column="order_id"/> <result property="orderNo" column="order_no"/> <result property="createdAt" column="created_at"/> <!-- one-to-one: order -> user --> <association property="user" javaType="User"> <id property="id" column="u_id"/> <result property="userName" column="u_name"/> </association> <!-- one-to-many: order -> item list --> <collection property="items" ofType="OrderItem"> <id property="id" column="item_id"/> <result property="productId" column="product_id"/> <result property="quantity" column="quantity"/> </collection></resultMap><select id="findOrderDetail" resultMap="OrderWithItems"> SELECT o.id AS order_id, o.order_no, o.created_at, u.id AS u_id, u.user_name AS u_name, i.id AS item_id, i.product_id, i.quantity FROM t_order o JOIN t_user u ON u.id = o.user_id LEFT JOIN t_order_item i ON i.order_id = o.id WHERE o.id = #{id}</select>associationhandles "an order belongs to one user";collectionhandles "an order contains many items"- The trick is in the SQL: alias the clashing columns from different tables (
u_id/item_id), then letresultMapfile them home - If field and column names already agree and camelCase maps cleanly, you can skip
resultMapentirely and useresultType
The demo above runs the /users/42 request. You will see that the mapper is only the last link: Controller takes params -> Service orchestrates business and transactions -> Mapper runs SQL. Understand this chain and you can reason about which layer a transaction belongs on.
That chain reads like seven nouns, but it is really a piece of code you can step through. Below is userMapper.findById(1L) spread out: the seven lines on the left are the order actually taken, the right panel shows what you are holding at each moment. Press step repeatedly and watch step 3 — that is where Invalid bound statement (not found) is born:
User u = userMapper.findById(1L); // you own an interface, no implementation class// MapperProxy.invoke(proxy, method, args) // the JDK dynamic proxy is the only entry// mapperInterface + methodName form the id // e.g. com.example.UserMapper.findByIdMappedStatement ms = cfg.getMappedStatement(id); // use that key to find the SQL// SqlSession.selectOne(ms, param) // the execution entry you never call// Executor.query(ms, param) // asks the first-level cache first// ResultSetHandler rebuilds a User // camelCase or resultMap lands here| userMapper really is | a JDK proxy object |
| method name | findById |
| implementation class | does not exist |
UserService.listUserMapper.findByIdDynamic SQL is MyBatis's most practical feature: one XML fragment composes different SQL depending on the parameters. Here is each tag, copy-ready.
dynamic SQL is a fill-in-the-blanks official form. You draft a template — "Mr/Ms ___ is requesting leave because ___" — and only the sentences needed today get filled in. <where> and <set> are the pedantic proofreader: they delete a leftover leading "AND" or trailing comma so the document stays grammatical (otherwise SQL throws a syntax error). <foreach> is like joining a list of names with commas — except when there is not a single name, what you print is an empty "( )", and the reader has no idea what to do with it (IN () syntax error). Remember those three roles and you never have to memorise tag names again.
First question, deliberately light:
<select id="search" resultType="User"> SELECT * FROM t_user WHERE 1 = 1 <if test="keyword != null and keyword != ''"> AND user_name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="status != null"> AND status = #{status} </if></select>testholds an OGNL expression; for strings check both!= nulland!= ''WHERE 1 = 1is the old trick to absorb the firstAND; the<where>in the next subsection kills it gracefully
<choose> <when test="orderBy == 'price'">ORDER BY price ASC</when> <when test="orderBy == 'sales'">ORDER BY sales DESC</when> <otherwise>ORDER BY id DESC</otherwise> <!-- fallback so there is always a sort --></choose><select id="search2" resultType="User"> SELECT * FROM t_user <where> <if test="keyword != null and keyword != ''"> AND user_name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="status != null">AND status = #{status}</if> </where></select><update id="updateSelective"> UPDATE t_user <set> <if test="userName != null">user_name = #{userName},</if> <if test="status != null">status = #{status},</if> </set> WHERE id = #{id}</update><where>: adds WHERE only when there is content, and strips a leadingAND/OR<set>: adds SET only when there is content, and strips a trailing comma<trim prefix="(" suffix=")" prefixOverrides="AND |OR ">is the general form when you need custom affixes
<!-- IN query: expand a collection into (1, 2, 3) --><select id="findByIds" resultType="User"> SELECT * FROM t_user WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach></select><!-- Batch insert: multiple VALUES in a single INSERT --><insert id="batchInsert"> INSERT INTO t_user (user_name, status) VALUES <foreach collection="list" item="u" separator=","> (#{u.userName}, #{u.status}) </foreach></insert>collection: uselistfor aList,arrayfor an array, or the name given by@Paramopen/close/separatorwrap and delimit; insideforeach,#{id}resolves to the current item
<sql id="baseColumns">id, user_name, status, created_at</sql><select id="findById" resultType="User"> SELECT <include refid="baseColumns"/> FROM t_user WHERE id = #{id}</select>- Extract a repeated column list into
<sql>; change it once and everything updates <include>can also take<property>children for parameterised reuse
Simple SQL reads better right on the interface — a joy during prototyping:
public interface UserMapper { @Select("SELECT id, user_name, status FROM t_user WHERE id = #{id}") @Results(id = "userMap", value = { @Result(column = "id", property = "id", id = true), @Result(column = "user_name", property = "userName"), @Result(column = "status", property = "status") }) User findById(Long id); @Insert("INSERT INTO t_user (user_name, status) VALUES (#{userName}, #{status})") @Options(useGeneratedKeys = true, keyProperty = "id") int insert(User user); @Update("UPDATE t_user SET status = #{status} WHERE id = #{id}") int updateStatus(@Param("id") Long id, @Param("status") Integer status);}The home turf of each style is clear:
| Dimension | Annotations | XML |
|---|---|---|
| Readability | Simple SQL reads well | Complex SQL stays structured |
| Dynamic SQL | Must wrap in <script>, awkward | Native tags, natural |
| Reuse | Fragments hard to reuse | <sql> / <include> at hand |
| Join mapping | @Results works but gets cramped | resultMap is clean |
| Recompile after editing SQL | Yes | No — just edit XML |
Point: **dynamic SQL, complex joins, reusable fragments** always go in XML; only a one- or two-line query deserves an annotation. Do not cram all three cases into annotations just to avoid one more file — that only doubles the maintenance cost.
The rawest way is to splice paging parameters into the SQL by hand:
<select id="page" resultType="User"> SELECT * FROM t_user ORDER BY id DESC LIMIT #{offset}, #{size}</select>It works, but you also write a separate COUNT(*) query and hand-compute offsets — not recommended. The common choice is the PageHelper plugin.
Its mechanism is clever: one moment before the query you set the paging parameters; PageHelper stores them in a ThreadLocal, then a MyBatis interceptor rewrites the pending SQL (appending LIMIT and running a COUNT), and clears the ThreadLocal afterwards.
// 1. Dependency: pagehelper-spring-boot-starter (version aligned with Boot)// 2. Usage: right next to the query — no other SQL in betweenPageHelper.startPage(1, 10); // page 1, 10 rows eachList<User> list = userMapper.search(null, 1, null, null);PageInfo<User> pageInfo = new PageInfo<>(list); // wraps total count and pagesSystem.out.println(pageInfo.getTotal());System.out.println(pageInfo.getPages());startPageand the real query must be adjacent; inserting any other query in between lets the paging state leakPageInforeads the COUNT result from theThreadLocaland derivestotal/pages- Remember:
PageHelper.startPageaffects only the very next SQL statement
"Affects only the first one" is not something you believe by reading — you believe it once you see the rewrite. Click through these five boxes; box ① (where the parameters wait) and box ⑤ (when they are cleared) together decide what happens if you squeeze another query in between:
Once a table passes a few million rows, deep paging with LIMIT offset, size gets slower and slower (it scans every preceding row). Use cursor paging anchored on the primary key instead:
<!-- Cursor paging: WHERE id < #{lastId} ORDER BY id DESC LIMIT #{size} always scans only size rows, so page N is as fast as page 1 --><select id="nextPage" resultType="User"> SELECT * FROM t_user <where> <if test="lastId != null">AND id < #{lastId}</if> </where> ORDER BY id DESC LIMIT #{size}</select>Trap: cursor paging gives up "jump to page N". It fits feeds and order lists — the "keep scrolling" case. Admin tables that must jump precisely to page N still need LIMIT offset or PageHelper.
Beyond paging, MyBatis has one more genuinely numeric knob, and you already saw its cost in the previous article: an unattended slow query keeps grip of a connection until the pool drains (the scene in article 28, section 12). default-statement-timeout is the fuse fitted to every statement — measured in seconds, and the default 0 means no limit at all. Drag it and see where it earns its keep:
- 5-10 seconds is where most online systems land
- Past the line the statement is cancelled: the connection returns immediately and one query cannot drain the pool
- The failure is explicit and carries the statement, so the log names the offender
- Pair it with MySQL's long_query_time — reading both is what turns a timeout into a cause
MyBatis has two cache levels, and their defaults differ sharply — most caching bugs trace back to them:
| Level | Scope | Default | Toggle | Classic pitfall |
|---|---|---|---|---|
| First level | Same SqlSession | On | Cannot be disabled (only tuned) | Reading stale data within one session |
| Second level | Same namespace (across SqlSessions) | Off | <cache/> or @CacheNamespace | In a cluster each node caches its own copy and they go stale after a write |
The whole difference between the two levels is the single word scope: the first travels with the SqlSession (with Spring, that is one transaction), the second with the namespace (across sessions, but only inside this process). Keep the two columns apart and each of the "mysterious behaviours" below gets an owner:

The first-level cache lives inside the SqlSession. Within one SqlSession, identical SQL and parameters are queried only once; the second call hits the cache. Inside one transaction this is usually fine, but three scenarios involving invalidation / dirty reads deserve memorising:
// Scenario: inside a single SqlSession (in Spring, a single transaction)User u1 = mapper.findById(1L); // queries the DB, fills the first-level cacheUser u2 = mapper.findById(1L); // cache hit, no query// When this cache is cleared:// 1) any insert / update / delete runs (flushCache)// 2) sqlSession.clearCache() is called explicitly// 3) the SqlSession closes (transaction ends)The real trap: you assume every mapper call re-queries the DB, but within one transaction the second read comes from the cache. If another thread updates that row in between, you read the old value — the so-called "dirty read within one SqlSession".
The second-level cache is off by default. Switch it on and query results of a namespace are cached across SqlSessions, saving repeated queries across requests. But in a cluster, each node holds its own copy: node A writes, node B's cache never invalidates, and B serves stale data. Therefore:
- enable it only for dictionary-like, read-heavy, rarely-written data where brief inconsistency is acceptable
- in a cluster, prefer a centralised cache like Redis over the local second-level cache
- with either level, if you need "read the latest value immediately after a write", clear the cache explicitly or bypass it
Second question: it asks you to line up the transaction boundary with the cache boundary (many engineers never realise they are the same thing):
Trap one: map-underscore-to-camel-case off, and every field is null.
The DB column is user_name, the field is userName. With the flag off, MyBatis matches column names exactly, finds no userName column, and the field stays null. The table has rows, the SQL returned them — yet every field is empty. People debug for ages thinking it is a SQL problem when it is the mapping flag. Fix: switch it on globally, or write an explicit resultMap.
Trap two: #{} does not work for a sort field, and ${} is risky.
ORDER BY #{orderBy} becomes ORDER BY ?; the database treats ? as a constant value, so the sort silently fails (or errors). But ${orderBy} reopens the injection surface. The only right answer is a whitelist: allow only a predefined set of column names and fall back to a default otherwise — see the ALLOWED code in section 3. The same rule applies to dynamic table names and dynamic columns.
whenever you see ${}, immediately ask "can the user control this value?". If yes, whitelist it; only if no is it even tentatively safe.
All four demos below run the real kernel (WASM); click a parameter and it reruns. Suggested order: use the first to build the "interface → SQL" feel, the second to watch dynamic SQL assembly, the third to see why a cache can lie to you inside one transaction, and the fourth for what a paging plugin actually rewrites.
What to stare at for each parameter:
| Parameter | What you will see | Matches section |
|---|---|---|
| One select | The full relay MapperProxy → SqlSession → Executor → StatementHandler → ResultSetHandler | The animation in section 3 |
| Dynamic SQL | <if> / <where> deciding whether a fragment enters the final SQL | Section 5 |
| First-level cache | The second identical query inside one SqlSession emits no SQL at all | Section 8 |
| Second-level cache | Cross-SqlSession hits on the namespace cache; any write clears the whole block | Section 8 |
| Paging rewrite | LIMIT appended to your SQL plus an extra COUNT run | Section 7 |
watch "first-level cache" and "paging rewrite" back to back and two commonly confused facts click into place — PageHelper's ThreadLocal applies only to the very next SQL, and the first-level cache's scope is the SqlSession (≈ one transaction). Those two properties explain most of MyBatis's "spooky behaviour".
Everything MyBatis took over was once yours to write. Switch to "ResultSet → object" to see the per-column boilerplate, then to "forgetting to release" to see connections leaking — that is precisely which layers a semi-automatic ORM removes.
A mapper only produces SQL; that SQL still has to borrow a connection. Switch to "queue at the limit" and "wait timeout": a single query whose foreach expands into thousands of IN values can hold a connection long enough that the whole pool starts queueing and eventually times out everywhere. MyBatis performance problems show their symptoms on the connection pool.
An <update> returning 0 affected rows does not mean "the statement failed" — it may simply have matched nothing. And if an outer method throws and rolls back, your earlier successful writes disappear with it. Switch to "rollback rules" to watch rollbackFor interact with checked exceptions:
remember these three layers — the mapper produces SQL → the pool supplies the connection → the transaction decides whether the changes count. Diagnose any data-access problem by peeling from the top down.
Enough buttons — type it yourself now. This console talks to the same in-browser Java kernel, and every reply is computed there: start with beans to check the mappers really were registered, use cond to flip a switch and watch the container step aside, then walk the three themes of this article (prepared statements, dynamic SQL, the first-level cache) with lab:
lab mybatis l1 followed straight by lab pool timeout makes a useful pair: the first shows "the second identical query inside one SqlSession sends no statement", the second shows "one long-held statement can drain the whole pool". The same wish — fewer statements — is what saves you in the first and what ambushes you in the second.
| Error text (fragment) | Actual cause | 30-second self-rescue | Dig deeper in |
|---|---|---|---|
org.apache.ibatis.binding.BindingException: Invalid bound statement (not found) | Interface method and XML are not bound: wrong namespace, id differing from the method name, XML not covered by mapper-locations, or XML placed under src/main/java and never packaged | Pull three threads: ① is namespace the interface's fully-qualified name ② does id equal the method name ③ does target/classes/mapper/ actually contain that XML | Sections 2 and 3 |
java.sql.SQLSyntaxErrorException: ... near 'DROP TABLE' / syntax error | ${} pasted external input into the SQL structure — a live injection scene | Change that spot to #{}; if a dynamic table/column is genuinely needed, whitelist it server-side first (see ALLOWED in section 3) | Section 3 |
SQLSyntaxErrorException: ... near ')', log shows WHERE id IN () | <foreach> received an empty collection but still emitted open="(" close=")", producing empty parentheses | Guard the call and return an empty list early, or wrap the whole IN (...) block in <if test="ids != null and ids.size() > 0"> | Section 5.4 |
No error at all, yet every entity field is null (the table clearly has rows) | map-underscore-to-camel-case off, or a resultMap whose column was written as the Java property name | Turn the camelCase flag on; for joins and aliases check that every <result column="..."/> matches the column alias in the SQL exactly | Section 9 |
TooManyResultsException: Expected one result (or null) to be returned by selectOne(), but found: N | The method returns a single object while the SQL returns several rows (usually row multiplication after a join, or a missing unique condition) | Return List<T>, or make the SQL guarantee at most one row (unique index / LIMIT 1) | Section 4 |
PersistenceException: Parameter 'status' not found. Available parameters are [arg1, arg0, param1, param2] | A multi-parameter method lacks @Param, so the XML can only reach the arg0/param1 aliases | Add @Param("status") to each parameter; pass a single object instead and reference its properties directly | Section 4 |
BindingException: Error evaluating expression 'keyword != null'. Cause: ... OgnlException | The <if test> references a parameter name that was never declared, so OGNL cannot resolve it | Compare @Param names against the test expression including case; string checks need keyword != null and keyword != '' | Section 5 |
| Stale reads after clustering; restarting one node "fixes" it | Each JVM keeps its own local second-level cache; a write on node A never invalidates node B | Remove <cache/> and move to a centralised cache such as Redis; give dictionary data an expiry anyway | Section 8 |
the two nastiest rows above raise no error at all (fields all null; reading stale data). Their shared remedy is the same habit: turn on log-impl: org.apache.ibatis.logging.stdout.StdOutImpl and always judge by "how many SQL statements really ran, with what parameters, returning which rows".
The first row of that table is the one beginners cannot parse, even though the message contains the answer. Here is a real stack — do not read the analysis, just click the frame you think is guilty:
You followed a tutorial exactly: mapper interface written, XML written, startup clean, injection fine — then the first call to findById throws BindingException. The newcomer's first thought is that MyBatis is broken.
Goal: get the canonical structure working — one line on the interface, all SQL in XML — and watch dynamic SQL assemble three different statements. All files given, copy them directly.
package com.example.demo.entity;public class User { private Long id; private String userName; // DB column user_name private Integer status; // getter / setter / toString omitted}package com.example.demo.mapper;import com.example.demo.entity.User;import org.apache.ibatis.annotations.Mapper;import org.apache.ibatis.annotations.Param;import java.util.List;@Mapperpublic interface UserMapper { User findById(Long id); List<User> search(@Param("keyword") String keyword, @Param("status") Integer status); List<User> findByIds(@Param("ids") List<Long> ids); int batchInsert(@Param("list") List<User> list); int updateSelective(User user);}src/main/resources/mapper/UserMapper.xml (it must live under resources — put it in the java source folder and you get Invalid bound statement):
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.demo.mapper.UserMapper"> <sql id="baseColumns">id, user_name, status</sql> <select id="findById" resultType="User"> SELECT <include refid="baseColumns"/> FROM t_user WHERE id = #{id} </select> <select id="search" resultType="User"> SELECT <include refid="baseColumns"/> FROM t_user <where> <if test="keyword != null and keyword != ''"> AND user_name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="status != null">AND status = #{status}</if> </where> ORDER BY id DESC </select> <select id="findByIds" resultType="User"> SELECT <include refid="baseColumns"/> FROM t_user WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach> </select> <insert id="batchInsert"> INSERT INTO t_user (user_name, status) VALUES <foreach collection="list" item="u" separator=",">(#{u.userName}, #{u.status})</foreach> </insert> <update id="updateSelective"> UPDATE t_user <set> <if test="userName != null">user_name = #{userName},</if> <if test="status != null">status = #{status},</if> </set> WHERE id = #{id} </update></mapper>The test that drives it:
@SpringBootTestclass UserMapperTest { @Autowired UserMapper mapper; @Test void dynamicSql() { mapper.search(null, null); // (1) no filter mapper.search("ali", null); // (2) keyword only mapper.search(null, 1); // (3) status only }}Expected console SQL log (only present with log-impl enabled; the shape must match):
===> Preparing: SELECT id, user_name, status FROM t_user ORDER BY id DESC===> Parameters: <>===> Preparing: SELECT id, user_name, status FROM t_user WHERE user_name LIKE ? ORDER BY id DESC===> Parameters: %ali%(String)===> Preparing: SELECT id, user_name, status FROM t_user WHERE status = ? ORDER BY id DESC===> Parameters: 1(Integer)===> Preparing: INSERT INTO t_user (user_name, status) VALUES (?, ?), (?, ?), (?, ?)===> Parameters: alice(String), 1(Integer), bob(String), 1(Integer), cindy(String), 0(Integer)===> Preparing: UPDATE t_user SET status = ? WHERE id = ?===> Parameters: 2(Integer), 1(Long)Confirm four things against that log:
- In case (1) there is not even a WHERE — that is
<where>behaving correctly, not a bug - In (2) and (3) the leading
ANDwas stripped and every parameter is a?(the doing of#{}) <sql>+<include>keep the column list defined in exactly one place- The UPDATE mentions only
status, because<set>assembles non-null fields and deletes the trailing comma
- Set
map-underscore-to-camel-casetofalseand rerun → you will observe:<=== Rowclearly has data, yettoString()printsuserName=null. That is trap one in the flesh — worth more than reading it ten times. - Replace
<where>insearchwith a hard-codedWHERE 1 = 1→ you will observe: same results today, but the day someone forgets theANDprefix the SQL breaks. Feel what<where>was absorbing on your behalf. - Call
findByIds(List.of())with an empty collection → you will observe: the log showsWHERE id IN ()and aSQLSyntaxErrorExceptionis thrown. Fix it yourself: wrap the block in<if test="ids != null and ids.size() > 0">, or short-circuit in the service. - Add
ORDER BY ${orderBy}tosearchand passid; DROP TABLE t_user--→ you will observe: the logged SQL structure really did change. Delete that line immediately afterwards, and write in your notes the iron rule "${}only ever receives whitelisted values". - Change
findById's return type toList<User>while leavingSELECT * FROM t_userunfiltered → do the reverse and meetTooManyResultsExceptiononce, so you recognise it instantly next time.
Requirement: implement GET /admin/orders?keyword=&status=&page=1&size=20&sortField=id&order=desc with MyBatis, covering multi-condition filtering + paging + sorting. Acceptance checklist:
- [ ] One method on the mapper interface, all SQL in XML; filters expressed with
<where>+<if>and noWHERE 1=1anywhere - [ ] The sort field goes through a server-side whitelist (
Set<String> ALLOWED) withidas fallback; direction allows only the two literalsASC/DESC, expressed with<choose> - [ ] Two paging implementations, each with a documented use case: PageHelper (
startPageglued to the query) and cursor paging (WHERE id < #{lastId}); state which one serves "jump to page N" - [ ] Multi-value status filters expand with
<foreach>, and an empty collection emits no SQL — it returns an empty page - [ ] With
log-implon, paste into the README the SQL actually produced by each combination (at least four: unfiltered / single condition / multi-condition plus sort / cursor next-page) - [ ] Load-test the endpoint with 100 concurrent clients while capturing
hikaricp.connections.pendingand the MySQL slow query log; decide whether the bottleneck is SQL, mapping or the pool, and write a five-line conclusion - [ ] Bonus: enable
<cache/>on one query, then change the data from a second instance and reproduce "the other node serves stale rows", and explain why production should use Redis instead of the local second-level cache
Finish these three tiers and your MyBatis skill moves from "knowing the tags" to "knowing what happens at every step, and which layer to inspect when it breaks".
when Invalid bound statement (not found) appears, what are your three threads? Answer: whether namespace equals the interface's fully-qualified name, whether id equals the method name, and whether that XML exists in the compiled target/classes.
state the essential difference between #{} and ${} in one sentence. Answer: one is a pre-compiled parameter (a value can never enter the sentence pattern), the other is string concatenation (a value can rewrite the pattern).
what does <where> emit when not a single <if> matched? Answer: nothing — not even the WHERE keyword, which is why WHERE 1=1 is unnecessary.
inside one transaction, does a second findById(1L) send SQL? Answer: by default no — the first-level cache hits within the SqlSession, which is also why you cannot see another thread's freshly committed value.
why must you not enable the second-level cache in a cluster? Answer: every JVM holds its own copy; a write on node A never invalidates node B, so you serve stale data. Centralised caching is the answer.
Mantra: **the interface orders, the XML cooks; leave camelCase off and every field is null. `#{}` fills numbers, `${}` rewrites sentences — whitelist before you ever use it. `<where>` trims the head, `<set>` trims the tail, and `<foreach>` must not face an empty collection. The first-level cache follows the transaction; keep the second-level cache out of the cluster.**
All of MyBatis condenses into four lines — use #{} whenever you can, and whitelist before any ${}; compose dynamic SQL with tags and put complex queries in XML; map joins with resultMap and check the camelCase flag when fields do not line up; trust only the first-level cache by default and never count on the second in a cluster. Memorise these four and you will dodge the vast majority of MyBatis traps in advance.