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 框架查詢數據庫的小問題,這其實是數據庫事務與隔離級別的問題,同時這也是一個業務上的問題,應該在插入之前進行驗重。
關於數據庫隔離級別,這裏只是小小提了一下,以後有空再總結吧。