基于數(shù)據(jù)庫鎖實現(xiàn)防重復(fù)提交
在 Web 應(yīng)用開發(fā)中,重復(fù)提交問題是一個常見的挑戰(zhàn)。當(dāng)用戶由于網(wǎng)絡(luò)延遲、誤操作等原因,多次點擊提交按鈕時,可能會導(dǎo)致相同的數(shù)據(jù)被多次插入到數(shù)據(jù)庫中,從而引發(fā)數(shù)據(jù)一致性問題。
為了解決這個問題,我們可以采用 token 機制,大多數(shù)實現(xiàn)是基于Redis實現(xiàn),今天介紹如何結(jié)合數(shù)據(jù)庫的悲觀鎖或樂觀鎖來實現(xiàn)對請求的有效驗證,確保同一操作不會被重復(fù)執(zhí)行。

一、Token 機制原理
Token 機制的核心思想是在用戶訪問頁面時,后端服務(wù)生成一個唯一的 token,并返回給前端。前端在用戶提交請求時,將這個 token 一并發(fā)送到后端。后端接收到請求后,驗證該 token 是否有效,即是否已經(jīng)被使用過。如果 token 未被使用過,則處理此次請求,并將該 token 標記為已使用;如果 token 已被使用過,則判定為重復(fù)提交,拒絕處理此次請求。
二、實現(xiàn)
1. 使用數(shù)據(jù)庫悲觀鎖驗證 Token
悲觀鎖認為數(shù)據(jù)在被訪問時很可能被其他事務(wù)修改,因此在獲取數(shù)據(jù)時就對其加鎖,防止其他事務(wù)對其進行修改。在驗證 token 時,我們可以利用悲觀鎖來確保在同一時刻只有一個事務(wù)能夠處理帶有特定 token 的請求。
假設(shè)我們有一個token_info表,用于存儲 token 信息,表結(jié)構(gòu)如下:
CREATE TABLE token_info (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(255) NOT NULL UNIQUE,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_token (token)
);驗證 token 的 SQL 語句及相關(guān)代碼如下:
public interface TokenMapper {
// 插入Token到數(shù)據(jù)庫
@Insert("INSERT INTO token_info (token) VALUES (#{token})")
int insertToken(String token);
// 使用悲觀鎖查詢Token
@Select("SELECT * FROM token_info WHERE token = #{token} FOR UPDATE")
Token selectTokenForUpdate(String token);
// 刪除Token
@Delete("DELETE FROM token_info WHERE token = #{token}")
int deleteToken(String token);
}Token的Service層,處理業(yè)務(wù)邏輯:
@Service
public class TokenService {
@Autowired
private TokenMapper tokenMapper;
// 生成Token并存儲到數(shù)據(jù)庫
public String generateToken() {
String token = UUID.randomUUID().toString();
tokenMapper.insertToken(token);
return token;
}
// 驗證Token的有效性
@Transactional
public boolean validateToken(String token) {
Token dbToken = tokenMapper.selectTokenForUpdate(token);
if (dbToken != null) {
tokenMapper.deleteToken(token);
return true;
}
return false;
}
}在控制器中使用服務(wù)層方法進行驗證:
@RestController
public class SubmissionController {
private final TokenService tokenService;
public SubmissionController(TokenService tokenService) {
this.tokenService = tokenService;
}
@PostMapping("/submit")
public String submit(@RequestParam String token) {
if (tokenService.validateToken(token)) {
// 處理正常的提交邏輯
return "提交成功";
} else {
return "重復(fù)提交,請求被拒絕";
}
}
}2. 使用數(shù)據(jù)庫樂觀鎖驗證 Token
樂觀鎖認為數(shù)據(jù)在被訪問時很少會被其他事務(wù)修改,因此在獲取數(shù)據(jù)時不會對其加鎖,而是在更新數(shù)據(jù)時檢查數(shù)據(jù)是否被其他事務(wù)修改過。在驗證 token 時,我們可以通過版本號來實現(xiàn)樂觀鎖機制。
首先,修改token_info表結(jié)構(gòu),添加一個版本號字段version:
CREATE TABLE token_info (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(255) NOT NULL UNIQUE,
version INT DEFAULT 0,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_token (token)
);驗證 token 的 SQL 語句及相關(guān)代碼如下:
// Token 的 Mapper 接口,定義與數(shù)據(jù)庫交互的方法
public interface TokenMapper {
// 插入 Token 到數(shù)據(jù)庫
@Insert("INSERT INTO token_info (token, version) VALUES (#{token}, 0)")
int insertToken(String token);
// 查詢 Token 及其版本號
@Select("SELECT * FROM token_info WHERE token = #{token}")
Token selectToken(String token);
// 使用樂觀鎖更新 Token 的版本號
@Update("UPDATE token_info SET version = version + 1 WHERE token = #{token} AND version = #{version}")
int updateTokenVersion(@Param("token") String token, @Param("version") Integer version);
// 刪除 Token
@Delete("DELETE FROM token_info WHERE token = #{token}")
int deleteToken(String token);
}Token的Service層,處理業(yè)務(wù)邏輯:
@Service
public class TokenService {
@Autowired
private TokenMapper tokenMapper;
// 生成 Token 并存儲到數(shù)據(jù)庫
public String generateToken() {
String token = UUID.randomUUID().toString();
tokenMapper.insertToken(token);
return token;
}
// 驗證 Token 的有效性
//token用完即刪除,新的token版本號永遠為0,也可以不查詢庫默認0
@Transactional
public boolean validateToken(String token) {
Token dbToken = tokenMapper.selectToken(token);
if (dbToken != null) {
int rowsAffected = tokenMapper.updateTokenVersion(dbToken.getToken(), dbToken.getVersion());
if (rowsAffected > 0) {
// 驗證成功,刪除 Token
tokenMapper.deleteToken(token);
return true;
}
}
return false;
}
}在控制器中使用服務(wù)層方法進行驗證:
@RestController
public class SubmissionController {
private final TokenService tokenService;
public SubmissionController(TokenService tokenService) {
this.tokenService = tokenService;
}
@PostMapping("/submit")
public String submit(@RequestParam String token) {
if (tokenService.validateToken(token)) {
// 處理正常的提交邏輯
return "提交成功";
} else {
return "重復(fù)提交,請求被拒絕";
}
}
}3. Redis
@Slf4j
@Service
public class TokenUtilService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 存入 Redis 的 Token 鍵的前綴
*/
private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";
/**
* 創(chuàng)建 Token 存入 Redis,并返回該 Token
*
* @param value 用于輔助驗證的 value 值
* @return 生成的 Token 串
*/
public String generateToken(String value) {
// 實例化生成 ID 工具對象
String token = UUID.randomUUID().toString();
// 設(shè)置存入 Redis 的 Key
String key = IDEMPOTENT_TOKEN_PREFIX + token;
// 存儲 Token 到 Redis,且設(shè)置過期時間為5分鐘
redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
// 返回 Token
return token;
}
/**
* 驗證 Token 正確性
*
* @param token token 字符串
* @param value value 存儲在Redis中的輔助驗證信息
* @return 驗證結(jié)果
*/
public boolean validToken(String token, String value) {
// 設(shè)置 Lua 腳本,其中 KEYS[1] 是 key,KEYS[2] 是 value
String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
// 根據(jù) Key 前綴拼接 Key
String key = IDEMPOTENT_TOKEN_PREFIX + token;
// 執(zhí)行 Lua 腳本
Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value));
// 根據(jù)返回結(jié)果判斷是否成功成功匹配并刪除 Redis 鍵值對,若果結(jié)果不為空和0,則驗證通過
if (result != null && result != 0L) {
log.info("驗證 token={},key={},value={} 成功", token, key, value);
return true;
}
log.info("驗證 token={},key={},value={} 失敗", token, key, value);
return false;
}
}三、最后
通過使用 token 機制結(jié)合數(shù)據(jù)庫的悲觀鎖或樂觀鎖,我們可以有效地避免用戶重復(fù)提交請求,保證數(shù)據(jù)的一致性和系統(tǒng)的穩(wěn)定性。悲觀鎖適用于數(shù)據(jù)競爭較為激烈的場景,能夠確保數(shù)據(jù)的完整性,但可能會影響系統(tǒng)的并發(fā)性能;樂觀鎖則適用于數(shù)據(jù)沖突較少的場景,能夠提高系統(tǒng)的并發(fā)處理能力,但在數(shù)據(jù)沖突較多時可能會導(dǎo)致多次重試。在實際應(yīng)用中,我們需要根據(jù)具體的業(yè)務(wù)場景和數(shù)據(jù)特點選擇合適的鎖機制來實現(xiàn) token 驗證邏輯。



























