基于数据库锁实现防重复提交
在 Web 应用开发中,重复提交问题是一个常见的挑战。当用户由于网络延迟、误操作等原因,多次点击提交按钮时,可能会导致相同的数据被多次插入到数据库中,从而引发数据一致性问题。
为了解决这个问题,我们可以采用 token 机制,大多数实现是基于Redis实现,今天介绍如何结合数据库的悲观锁或乐观锁来实现对请求的有效验证,确保同一操作不会被重复执行。
一、Token 机制原理
Token 机制的核心思想是在用户访问页面时,后端服务生成一个唯一的 token,并返回给前端。前端在用户提交请求时,将这个 token 一并发送到后端。后端接收到请求后,验证该 token 是否有效,即是否已经被使用过。如果 token 未被使用过,则处理此次请求,并将该 token 标记为已使用;如果 token 已被使用过,则判定为重复提交,拒绝处理此次请求。
二、实现
1. 使用数据库悲观锁验证 Token
悲观锁认为数据在被访问时很可能被其他事务修改,因此在获取数据时就对其加锁,防止其他事务对其进行修改。在验证 token 时,我们可以利用悲观锁来确保在同一时刻只有一个事务能够处理带有特定 token 的请求。
假设我们有一个token_info表,用于存储 token 信息,表结构如下:
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 语句及相关代码如下:
public interface TokenMapper {
// 插入Token到数据库
@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层,处理业务逻辑:
@Service
public class TokenService {
@Autowired
private TokenMapper tokenMapper;
// 生成Token并存储到数据库
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;
}
}
在控制器中使用服务层方法进行验证:
@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 "重复提交,请求被拒绝";
}
}
}
2. 使用数据库乐观锁验证 Token
乐观锁认为数据在被访问时很少会被其他事务修改,因此在获取数据时不会对其加锁,而是在更新数据时检查数据是否被其他事务修改过。在验证 token 时,我们可以通过版本号来实现乐观锁机制。
首先,修改token_info表结构,添加一个版本号字段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 语句及相关代码如下:
// Token 的 Mapper 接口,定义与数据库交互的方法
public interface TokenMapper {
// 插入 Token 到数据库
@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层,处理业务逻辑:
@Service
public class TokenService {
@Autowired
private TokenMapper tokenMapper;
// 生成 Token 并存储到数据库
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;
}
}
在控制器中使用服务层方法进行验证:
@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 "重复提交,请求被拒绝";
}
}
}
3. Redis
@Slf4j
@Service
public class TokenUtilService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 存入 Redis 的 Token 键的前缀
*/
private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";
/**
* 创建 Token 存入 Redis,并返回该 Token
*
* @param value 用于辅助验证的 value 值
* @return 生成的 Token 串
*/
public String generateToken(String value) {
// 实例化生成 ID 工具对象
String token = UUID.randomUUID().toString();
// 设置存入 Redis 的 Key
String key = IDEMPOTENT_TOKEN_PREFIX + token;
// 存储 Token 到 Redis,且设置过期时间为5分钟
redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
// 返回 Token
return token;
}
/**
* 验证 Token 正确性
*
* @param token token 字符串
* @param value value 存储在Redis中的辅助验证信息
* @return 验证结果
*/
public boolean validToken(String token, String value) {
// 设置 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 redisScript = new DefaultRedisScript<>(script, Long.class);
// 根据 Key 前缀拼接 Key
String key = IDEMPOTENT_TOKEN_PREFIX + token;
// 执行 Lua 脚本
Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value));
// 根据返回结果判断是否成功成功匹配并删除 Redis 键值对,若果结果不为空和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 机制结合数据库的悲观锁或乐观锁,我们可以有效地避免用户重复提交请求,保证数据的一致性和系统的稳定性。悲观锁适用于数据竞争较为激烈的场景,能够确保数据的完整性,但可能会影响系统的并发性能;乐观锁则适用于数据冲突较少的场景,能够提高系统的并发处理能力,但在数据冲突较多时可能会导致多次重试。在实际应用中,我们需要根据具体的业务场景和数据特点选择合适的锁机制来实现 token 验证逻辑。