1. 碎碎念
最近在業務中遇到一個問題,業務是這樣的:在插入新使用者時需要校驗使用者的某些資訊是否唯一,而在程式中校驗結果永遠是不唯一的。然後我把 MyBatis 列印的執行 SQL 語句拿了出來在資料庫中執行,發現沒有資料。
然後我就奇怪了,資料庫是同一個啊、SQL 是同一個啊、查詢結果都沒有變啊,為什麼執行的結果在程式裡面是 1,而在資料庫中是0。
難道是因為 MyBatis 和資料庫執行的結果不一樣?
後來我才明白不一致的原因。
我編寫了一個與實際業務類似的程式碼,用來模擬上述的問題。
2. 復現問題
2.1. 表結構
MySQL 資料庫中建立了一張使用者表,只有4個欄位。
CREATE TABLE `user` (
`user_id` varchar(36) NOT NULL COMMENT '使用者主鍵id',
`user_name` varchar(55) NULL DEFAULT NULL COMMENT '賬號',
`password` varchar(55) NULL DEFAULT NULL COMMENT '密碼',
`email` varchar(55) NULL DEFAULT NULL COMMENT '郵箱',
PRIMARY KEY (`user_id`) USING BTREE
);
複製程式碼
2.2. 專案依賴
示例專案是一個 SpringBoot 工程,pom 檔案中除了 web 依賴還有 mysql 的驅動、MyBatis 和 lombok。
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.2.6.RELEASE</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.8</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>
</dependencies>
複製程式碼
2.2. 業務
業務流程是這樣:新建一個使用者,在新建使用者之前首先校驗郵箱是否已在資料庫中存在,然後執行一些其他的業務,然後執行 insert 方法插入資料庫,然後執行一些其他的業務,最後再校驗 user_name 是否已存在。
@Slf4j
@RestController
public class TestController {
@Resource
private UserMapper userMapper;
/**
* springboot Dao層查詢結果與資料庫實際執行結果不一致?
*/
@GetMapping("test")
@Transactional(rollbackFor = RuntimeException.class)
public void transactionalDemo() {
// 要插入的資料
User user = new User();
user.setUserId("userId");
user.setUserName("planeswalker");
user.setPassword("password");
user.setEmail("[email protected]");
// 校驗郵箱
if (userMapper.countByEmail(user.getEmail())>0) {
throw new RuntimeException("插入失敗,user_id 重複");
}
// 執行插入使用者操作
userMapper.insert(user);
// 校驗 user_name
if (userMapper.countByName(user.getUserName())>0) {
throw new RuntimeException("插入失敗,user_name 重複");
}
log.info("do something others...");
}
}
複製程式碼
userMapper 介面類的程式碼如下:
@Repository
public interface UserMapper {
/**
* 查詢 email 是否重複
* @param email
* @return
*/
@Select("select count(*) from user where email=#{email}")
int countByEmail(String email);
/**
* 查詢 name 是否重複
* @param userName
* @return
*/
@Select("select count(*) from user where user_name=#{userName}")
int countByName(String userName);
}
複製程式碼
我承認這個方法確實可能不是特別好,比如校驗重複的方法為什麼有兩次,比如 user_id 校驗重複方法的合理性。但為了與我在專案中遇到的問題做模擬,這是很類似的,在專案中就是在插入後又校驗了一次(因為公用的校驗方法會查詢兩張表而其中一張表的資料是在校驗之前插入的)。
可能很多同學已經知道這個業務的問題所在了,先不多說,執行就行了。
2.3. 測試
當我在瀏覽器上訪問這個介面http://127.0.0.1:8080/test
後,控制檯輸出瞭如下的內容:
2020-05-27 14:07:09.183 DEBUG 18375 --- [nio-8080-exec-6] c.b.d.m.i.a.m.UserMapper.countByEmail : ==> Preparing: select count(*) from user where email=?
2020-05-27 14:07:09.208 DEBUG 18375 --- [nio-8080-exec-6] c.b.d.m.i.a.m.UserMapper.countByEmail : ==> Parameters: 123@gmail.com(String)
2020-05-27 14:07:09.218 DEBUG 18375 --- [nio-8080-exec-6] c.b.d.m.i.a.m.UserMapper.countByEmail : <== Total: 1
2020-05-27 14:07:09.233 DEBUG 18375 --- [nio-8080-exec-6] c.b.d.m.i.a.mapper.UserMapper.insert : ==> Preparing: INSERT INTO user ( user_id,user_name,password,email ) VALUES( ?,?,?,? )
2020-05-27 14:07:09.234 DEBUG 18375 --- [nio-8080-exec-6] c.b.d.m.i.a.mapper.UserMapper.insert : ==> Parameters: userId(String), planeswalker(String), password(String), 123@gmail.com(String)
2020-05-27 14:07:09.237 DEBUG 18375 --- [nio-8080-exec-6] c.b.d.m.i.a.mapper.UserMapper.insert : <== Updates: 1
2020-05-27 14:07:09.237 DEBUG 18375 --- [nio-8080-exec-6] c.b.d.m.i.a.m.UserMapper.countByName : ==> Preparing: select count(*) from user where user_name=?
2020-05-27 14:07:09.237 DEBUG 18375 --- [nio-8080-exec-6] c.b.d.m.i.a.m.UserMapper.countByName : ==> Parameters: planeswalker(String)
2020-05-27 14:07:09.238 DEBUG 18375 --- [nio-8080-exec-6] c.b.d.m.i.a.m.UserMapper.countByName : <== Total: 1
2020-05-27 14:07:09.250 ERROR 18375 --- [nio-8080-exec-6] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: 插入失敗,user_name 重複] with root cause
java.lang.RuntimeException: 插入失敗,user_name 重複
at com.biosan.databasehandler.TestController.transactionalDemo(TestController.java:43) ~[classes/:na]
at com.biosan.databasehandler.TestController$$FastClassBySpringCGLIB$$dadcb476.invoke(<generated>) ~[classes/:na]
......
複製程式碼
在第二個校驗方法的時候丟擲了錯誤,說明資料庫中存在相同 user_name 的資料,然後我又把 SQL 拿出來單獨去資料庫中執行,發現沒有資料!
不信邪的我又在第二個校驗方法上打了端點,當程式執行到此處時,它的執行結果是:
也就是說確實這時候存在這樣的資料!
而此時我又在資料庫當中查詢,竟然也查不到這條資料!
這就讓我開始考慮到,可能不是程式碼或者框架的原因,而是其他的問題了,比如資料庫事務。
2.4. 原因
我們知道在 SpringBoot 的介面上標註了 @Transactional
註解,就相當於開啟了一個事務。
MySQL 預設的事務隔離級別是讀已提交,即一個事務提交之後,它做的變更才會被其他事務看到。而在同一個事務中,如果先插入後查詢,如果查詢條件符合,是可以查詢到插入的資料的。
當我的程式在執行完 insert 方法後,又去根據 user_name 查詢,就可以查詢到插入的資料,而此時我直接在資料庫中查詢該 user_name,相當於又開啟了一個事務進行查詢,由於讀已提交的隔離級別,一個事務提交之後,它做的變更才會被其他事務看到,且業務方法未提交,所以在資料庫中查詢不到資料。
這也就是我在程式中和資料庫中用同樣的 SQL 進行查詢,但查詢結果卻不相同的原因。
2.5. 修復
這個問題從業務上來說原本就是不合理的,我在查詢重複資料時本就應該排除與將要插入資料相同 id 的資料,即 SQL 應該是:
select count(*) from user where user_name='planeswalker' and user_id!='userId'
複製程式碼
同時,驗證重複的業務邏輯應該在插入語句之前...
當然這就是後話了。
3. 小結
本文記錄了一個關於 SpringBoot+MyBatis 框架查詢資料庫的小問題,這其實是資料庫事務與隔離級別的問題,同時這也是一個業務上的問題,應該在插入之前進行驗重。
關於資料庫隔離級別,這裡只是小小提了一下,以後有空再總結吧。