文章目录
- 使用 Redis 实现 Java 项目中的防重复提交功能
使用 Redis 实现 Java 项目中的防重复提交功能
1. 引言
1.1 为何需要防重复提交功能
在Web应用程序中,用户可能会因为各种原因触发同一业务逻辑多次执行,例如网络延迟导致用户点击提交按钮多次、浏览器刷新页面导致表单重新提交等。这些重复提交不仅会增加系统的负担,还可能导致数据不一致、资金重复扣款等问题。
1.2 重复提交导致的问题
- 数据不一致:例如,在订单系统中,重复提交可能导致用户订单被创建多次。
- 资金风险:在支付系统中,重复提交可能会导致用户的账户被重复扣款。
- 资源浪费:服务器需要处理多余的请求,消耗不必要的计算资源。
1.3 引入 Redis 作为解决方案的一部分
Redis 是一个高性能的键值对存储系统,它支持多种数据结构,并且能够以极快的速度读写数据。利用 Redis 可以有效地实现防重复提交的功能,通过存储已处理过的请求标识符(例如请求ID或令牌),并在接收到新的请求时检查该标识符是否已经存在来避免重复处理。
2. 基础知识
2.1 Redis 简介
Redis (Remote Dictionary Server) 是一个开源的、基于内存的数据结构存储系统,它可以被用作数据库、缓存和消息中间件。Redis 支持多种数据结构,如字符串(strings)、散列(hashes)、列表(lists)、集合(sets)、有序集合(sorted sets)等,并且可以通过持久化功能将数据保存到磁盘上。
Redis 的特点
- 高性能:由于主要数据存储在内存中,因此 Redis 的读写速度非常快。
- 数据结构丰富:除了基本的键值对存储,还支持复杂的数据结构,方便实现多种应用场景。
- 持久化:支持两种持久化机制,RDB 快照和 AOF(Append Only File)日志,确保数据安全。
- 主从复制:支持主从架构,可用于数据备份和读写分离。
- 发布/订阅:支持发布/订阅模式,可以用于消息队列和实时通信场景。
2.2 Java 环境搭建
2.2.1 JDK 版本要求
为了兼容最新的 Redis 客户端库以及确保代码的安全性和性能,建议使用 Java 11 或更高版本。
2.2.2 必要的依赖库介绍
在 Java 项目中使用 Redis,通常有几种流行的客户端库可供选择,包括 Jedis 和 Lettuce。下面简单介绍这两种客户端:
- Jedis:这是一个轻量级的客户端,适合简单的 Redis 操作。使用 Maven 添加依赖如下:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.10.1</version>
</dependency>
- Lettuce:这是由 Spring Data Redis 提供的一个非阻塞的客户端,适用于高并发场景。添加 Maven 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
<version>2.7.6</version>
</dependency>
2.3 环境配置
2.3.1 Redis 服务器的安装与启动
- 下载 Redis:可以从官网下载最新稳定版的 Redis 服务器。
- 编译安装:对于 Linux 系统,可以在源码目录下运行
make
和make install
进行安装。 - 配置 Redis:编辑
/etc/redis/redis.conf
文件设置监听地址、端口等。 - 启动 Redis:在终端运行
redis-server
启动服务。
2.3.2 Java 应用程序中 Redis 客户端的配置
假设使用 Jedis 作为客户端,可以在 Spring Boot 应用中配置 Redis 连接池:
@Configuration
public class RedisConfig {
@Bean
public JedisConnectionFactory jedisConnectionFactory() {
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(100); // 最大连接数
poolConfig.setMaxIdle(20); // 最大空闲连接数
poolConfig.setMinIdle(10); // 最小空闲连接数
poolConfig.setTestOnBorrow(true); // 借出连接时进行测试
return new JedisConnectionFactory(poolConfig);
}
@Bean
public RedisTemplate<String, Object> redisTemplate(JedisConnectionFactory jedisConnectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(jedisConnectionFactory);
// 设置序列化器
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
}
3. 理论基础
3.1 HTTP 请求的特点
3.1.1 GET 和 POST 请求的区别
1. GET 请求:
- 主要用于获取资源,请求参数会显示在 URL 中。
- 请求是幂等的,即多次相同的 GET 请求会产生相同的结果。
- 请求可以被缓存,也可以被收藏为书签。
2. POST 请求:
- 主要用于提交数据给服务器,请求体中包含提交的数据。
- 请求不是幂等的,多次发送 POST 请求可能会产生不同的结果(例如多次创建资源)。
- 不会被缓存,也不会显示在浏览器历史记录中。
3.1.2 为什么 HTTP 是无状态协议
HTTP 协议本身是无状态的,这意味着每个请求都是独立的,服务器不会保留关于客户端的信息。当客户端向服务器发送请求时,服务器处理请求并返回响应,然后断开连接。如果服务器需要跟踪客户端的状态,则必须采用额外的机制,如 Cookie 或 Session。
3.2 前端表单提交
如何处理 F5 刷新导致的重复提交
在前端可以通过以下方法防止重复提交:
- 禁用按钮:在表单提交后禁用提交按钮,防止用户重复点击。
- 使用确认对话框:在用户再次提交表单之前显示确认对话框。
- 使用 Token:每次表单提交前生成一个唯一的 Token 并附加到表单中,提交后销毁该 Token,从而防止重复提交。
3.3 后端处理机制
为何仅依赖前端验证不够
虽然前端验证可以提高用户体验并减轻服务器的压力,但是它不能保证安全性,因为前端的代码是可以被修改的。恶意用户可以通过禁用 JavaScript 或直接发送 HTTP 请求绕过前端的验证。因此,后端也需要进行验证,确保数据的完整性和安全性。
4. 技术方案分析
4.1 基于 Token 的方法
1. 生成唯一标识符(Token)
当用户提交表单时,服务器生成一个唯一的 Token 并将其存储在 Session 或 Redis 中,同时将 Token 发送给客户端。客户端在提交表单时需要附带这个 Token。
2. 存储 Token 的方式
-
Session:将 Token 存储在服务器端的 Session 中。这种方式适用于较小规模的应用,但可能会影响性能,尤其是当 Session 数据量很大时。
-
Redis:将 Token 存储在 Redis 中。这种方法利用了 Redis 的高性能特性,适合高并发场景。可以使用哈希或字符串类型存储 Token。
4.2 基于时间戳的方法
使用时间戳防止短时间内重复提交
服务器记录最近一次成功提交的时间戳。当用户尝试再次提交时,服务器检查当前时间与上次提交的时间差是否小于某个阈值。如果是,则拒绝本次提交。
4.3 基于验证码的方法
1. 验证码的生成与校验
服务器生成一个随机的验证码,并将其显示给用户。用户需要正确输入验证码才能提交表单。验证码可以是图形验证码、短信验证码等形式。
2. 如何存储验证码信息
- Session:将验证码存储在服务器端的 Session 中。
- Redis:将验证码存储在 Redis 中,以便快速检索和删除。
4.4 其他方案
使用 UUID 等方式
为每次提交生成一个 UUID,然后检查该 UUID 是否已经被使用过。如果已经存在,则拒绝提交。这种方法类似于基于 Token 的方法,但是 UUID 是随机生成的,理论上不会重复。
综上所述,防止重复提交的方法有很多种,具体的选择取决于应用的需求、性能考虑和安全级别。实际应用中,可以结合多种方法来增强系统的健壮性。例如,可以结合使用 Token 和时间戳的方法,或者结合使用验证码和 UUID 方法等。
5. Redis 在防重复提交中的应用
5.1 Redis 数据类型选择
- String:适用于简单的键值对存储,例如存储 Token 或者 UUID。
- Set:可以用来存储一组不重复的元素,比如存储已经提交过的 Token,便于快速检查 Token 是否已存在。
- Hash:适合存储有关联的键值对,例如存储 Token 以及相关的元数据(如创建时间、过期时间等)。
5.2 Redis 操作命令
- SETNX:只有当 key 不存在时设置 key 的值,如果 key 已经存在,则不做任何操作。此命令可以用来实现基于 Token 的防重复提交。
- EXPIRE:为 key 设置一个生存时间(TTL),生存时间结束后 key 将自动被删除。可以用来控制 Token 或者时间戳的有效期限。
5.3 Redis 的事务机制
- Redis 支持事务(transaction),事务可以确保一系列命令作为一个整体执行,要么全部成功,要么全部失败。虽然 Redis 事务没有传统关系数据库的事务那么强大,但它保证了命令的原子性,可以用来确保防重复提交的操作不会被中断。
6. 实现细节
6.1 设计模式
- Singleton:确保 Redis 客户端在整个应用程序中只被初始化一次,避免多次创建连接带来的资源浪费。
- Factory:创建 Redis 操作类的工厂模式,可以方便地扩展不同的 Redis 操作逻辑。
6.2 代码示例
6.2.1 初始化 Redis 连接池
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
public class RedisPool {
private static JedisPool pool;
public static JedisPool getJedisPoolInstance() {
if (pool == null) {
synchronized (RedisPool.class) {
if (pool == null) {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(100);
config.setMaxIdle(50);
config.setMinIdle(20);
config.setMaxWaitMillis(2000L);
pool = new JedisPool(config, "localhost", 6379);
}
}
}
return pool;
}
}
6.2.2 创建 Redis 操作类
public class RedisService {
private JedisPool jedisPool;
public RedisService(JedisPool pool) {
this.jedisPool = pool;
}
public boolean setIfAbsent(String key, String value, int seconds) {
try (Jedis jedis = jedisPool.getResource()) {
String result = jedis.set(key, value, "NX", "EX", seconds);
return "OK".equals(result);
} catch (Exception e) {
// Handle exception
return false;
}
}
public String get(String key) {
try (Jedis jedis = jedisPool.getResource()) {
return jedis.get(key);
} catch (Exception e) {
// Handle exception
return null;
}
}
}
6.2.3 示例:如何实现基于 Token 的防重复提交
public class TokenBasedDuplicatePrevention {
private RedisService redisService;
public TokenBasedDuplicatePrevention(RedisService service) {
this.redisService = service;
}
public boolean submitForm(String token) {
// Generate or retrieve token here
// ...
if (redisService.setIfAbsent(token, "submitted", 300)) { // Token valid for 5 minutes
// Successful submission
return true;
} else {
// Duplicate submission detected
return false;
}
}
}
6.2.4 示例:基于时间戳的实现
public class TimestampBasedDuplicatePrevention {
private RedisService redisService;
public TimestampBasedDuplicatePrevention(RedisService service) {
this.redisService = service;
}
public boolean submitForm(String userId, long timestamp) {
String key = "lastSubmit:" + userId;
String lastTimestampStr = redisService.get(key);
long lastTimestamp = lastTimestampStr != null ? Long.parseLong(lastTimestampStr) : 0;
if (timestamp - lastTimestamp > 1000 * 60 * 5) { // 5 minutes
redisService.setIfAbsent(key, String.valueOf(timestamp), 300); // Store timestamp and expire after 5 minutes
return true;
} else {
return false;
}
}
}
6.2.5 示例:验证码的存储与验证
public class CaptchaVerification {
private RedisService redisService;
public CaptchaVerification(RedisService service) {
this.redisService = service;
}
public void storeCaptcha(String captchaId, String captchaValue, int seconds) {
redisService.setIfAbsent(captchaId, captchaValue, seconds);
}
public boolean verifyCaptcha(String captchaId, String userEnteredCaptcha) {
String storedCaptcha = redisService.get(captchaId);
if (storedCaptcha != null && storedCaptcha.equals(userEnteredCaptcha)) {
redisService.getJedisPool().del(captchaId); // Remove the captcha once it's used
return true;
}
return false;
}
}
6.3 错误处理
- 处理 Redis 连接失败等异常情况:在 Redis 操作类中捕获异常,并提供重试机制或回退到其他存储方案。
- 日志记录与监控:记录 Redis 的操作日志,并对关键操作进行监控,如连接数、操作延迟等。
7. 性能考虑
1. 并发处理
- 在高并发环境下,可以利用 Redis 的内存特性以及多线程客户端来处理大量的并发请求。同时,合理设置过期时间可以减轻内存压力。
2. 数据过期策略
- 设置合适的过期时间非常重要,太短可能导致合法请求被拒绝,太长则会占用不必要的内存空间。可以根据业务需求调整过期时间,一般建议在几分钟到半小时之间。
3. 容错与恢复
- Redis 节点故障后的应对措施:可以采用主从复制、哨兵模式或者集群模式来提高 Redis 的可用性。一旦发生故障,哨兵可以自动切换到备用节点,集群模式可以自动重新分配数据。
8. 安全性考量
8.1 数据加密
敏感数据的加密存储
在存储敏感数据(如 Token、验证码等)时,即使是在相对安全的 Redis 中,也应当考虑数据加密。这可以防止数据泄露时被未授权访问。
- 加密算法选择:可以使用 AES (Advanced Encryption Standard) 加密算法,这是一种广泛使用的对称加密算法。
- 密钥管理:密钥应当安全存储,并定期更换,以降低密钥泄露的风险。
- 实现示例:使用 Java 的
javax.crypto
包来进行加密和解密。
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.security.Key;
import java.util.Base64;
public class EncryptionUtil {
private static final String ALGORITHM = "AES";
private static final String KEY = "your_secret_key_here"; // 16, 24, or 32 bytes
public static String encrypt(String data) throws Exception {
Key secretKey = new SecretKeySpec(KEY.getBytes(), ALGORITHM);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
byte[] encryptedBytes = cipher.doFinal(data.getBytes());
return Base64.getEncoder().encodeToString(encryptedBytes);
}
public static String decrypt(String encryptedData) throws Exception {
Key secretKey = new SecretKeySpec(KEY.getBytes(), ALGORITHM);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, secretKey);
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedData));
return new String(decryptedBytes);
}
}
在 Redis 存储和读取数据时,可以先进行加密再存储,读取时先解密再使用。
8.2 攻击防护
防止恶意请求
- 限制访问频率:可以使用 Redis 的窗口滑动机制来限制 IP 地址或用户的访问频率。
- IP 黑名单:对于频繁发起恶意请求的 IP 地址,可以将其加入黑名单。
- 验证码机制:对于敏感操作,如密码重置或资金交易,可以要求用户提供验证码。
9. 测试与部署
9.1 单元测试
如何编写测试用例
- 测试 Redis 连接:验证 Redis 连接是否正常建立。
- 测试数据存取:验证数据能否正确地存入和取出。
- 测试 Token 生成与验证:验证 Token 生成的唯一性以及验证过程的准确性。
- 测试异常处理:验证当 Redis 无法连接或操作失败时,程序能否正确处理。
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class RedisServiceTest {
private RedisService service;
@Test
public void testSetIfAbsent() {
String token = "unique-token";
boolean result = service.setIfAbsent(token, "value", 300);
assertTrue(result);
}
@Test
public void testGet() {
String key = "test-key";
String value = "test-value";
service.setIfAbsent(key, value, 300);
assertEquals(value, service.get(key));
}
// Add more tests as needed
}
9.2 集成测试
测试整个流程
- 模拟用户行为:模拟用户提交表单的过程,验证防重复提交机制是否有效。
- 模拟异常情况:模拟 Redis 连接失败、Token 丢失等情况,验证系统的健壮性。
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class TokenBasedDuplicatePreventionIntegrationTest {
private TokenBasedDuplicatePrevention service;
@Test
public void testSubmitForm() {
String token = "unique-token";
boolean firstSubmit = service.submitForm(token);
assertTrue(firstSubmit);
boolean secondSubmit = service.submitForm(token);
assertFalse(secondSubmit);
}
// Add more integration tests as needed
}
9.3 压力测试
使用 JMeter 或其他工具模拟高负载
- 准备 JMeter 测试计划:创建一个 JMeter 测试计划,模拟大量并发用户提交表单的情况。
- 配置测试参数:设置线程数、循环次数、请求间隔等。
- 监控性能指标:监控 Redis 的响应时间、吞吐量等指标,确保系统能够在高并发情况下稳定运行。
<jmeterTestPlan version="1" properties="5.0" jmeter="5.0">
<hashTree>
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="High Load Test" enabled="true">
<stringProp name="TestPlan.comments">High load testing plan for form submissions.</stringProp>
<boolProp name="TestPlan.functional_mode">false</boolProp>
<boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
<elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">
<!-- Define variables here -->
</elementProp>
<stringProp name="TestPlan.user_define_classpath" />
</TestPlan>
<hashTree>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="Form Submissions" enabled="true">
<boolProp name="ThreadGroup.scheduler">false</boolProp>
<elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">
<boolProp name="LoopController.continue_forever">false</boolProp>
<elementProp name="LoopController.loops" elementType="stringProp" guiclass="PropertyPanel" testclass="stringProp" testname="Loops" enabled="true">
<stringProp name="stringProp.value">100</stringProp>
</elementProp>
</elementProp>
<stringProp name="ThreadGroup.num_threads">500</stringProp>
<stringProp name="ThreadGroup.ramp_time">10</stringProp>
<longProp name="ThreadGroup.start_time">0</longProp>
<longProp name="ThreadGroup.end_time">0</longProp>
<boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
</ThreadGroup>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Submit Form" enabled="true">
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">8080</stringProp>
<stringProp name="HTTPSampler.protocol">http</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path">/submit</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<stringProp name="HTTPSampler.postBodyRaw">token=unique-token&data=some-data</stringProp>
<boolProp name="HTTPSampler.useKeepAlive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
</HTTPSamplerProxy>
<hashTree />
</hashTree>
</hashTree>
</hashTree>
</jmeterTestPlan>
9.4 部署
部署到生产环境的最佳实践
- 环境隔离:使用不同的 Redis 实例或数据库来区分开发、测试和生产环境。
- 配置管理:使用环境变量或配置文件来管理不同环境下的配置。
- 监控与报警:设置监控系统,如 Prometheus 和 Grafana,用于监控 Redis 的性能指标,并配置报警机制。
- 备份与恢复:定期备份 Redis 数据,并确保可以快速恢复数据。
10. 案例分析
10.1 真实案例
让我们假设一个真实的在线支付平台项目,该项目需要确保用户在支付过程中不会因为网络延迟或者误操作而重复提交订单。该平台采用了一种基于Redis的防重复提交策略。
10.1.1 技术栈:
- 后端:Spring Boot
- 缓存:Redis
- 数据库:MySQL
10.1.2 实现细节:
1. 生成唯一Token:
- 当用户点击“确认支付”按钮时,后端生成一个唯一的Token,并将其与用户的会话ID相关联。
- Token 通过HTTPS安全协议发送给前端。
2. 前端存储Token:
- 前端收到Token后,将其存储在浏览器的LocalStorage中。
3.支付请求处理:
- 用户点击“支付”按钮时,前端从LocalStorage中获取Token并随支付请求一起发送。
- 后端接收到支付请求后,首先检查Token的有效性:
- 如果Token不存在于Redis中,则认为是重复提交,拒绝请求。
- 如果Token存在,后端则执行以下操作:
- 将Token与支付订单信息关联并存入数据库。
- 将Token从Redis中删除,以防止再次使用。
4.异常处理:
- 如果支付过程中出现任何错误,比如网络中断,后端会返回错误信息给前端。
- 前端显示错误信息,并提示用户重新尝试支付。
10.2 问题与挑战
1. 并发问题:
- 当多个用户同时提交支付请求时,可能会出现并发问题,导致数据不一致。
- 解决方案:使用Redis的原子操作(如SETNX)来确保Token的唯一性和互斥性。
2. Token时效性:
- Token的有效期需要合理设置,过短可能导致用户正常操作受阻,过长则增加被滥用的风险。
- 解决方案:设置合适的过期时间,例如5分钟,并允许用户在一定时间内重新提交。
3. 安全性问题:
- Token可能被截获或泄露。
- 解决方案:使用HTTPS协议传输Token,并定期更换Token以增强安全性。
11 总结本文要点
1. 安全性考量:
- 敏感数据的加密存储确保了数据的安全性。
- 攻击防护措施减少了恶意请求的影响。
2. 测试与部署:
- 单元测试和集成测试保证了功能的正确性。
- 压力测试确保了系统的稳定性。
- 部署最佳实践保障了系统的可靠性和可用性。
3. 案例分析:
- 通过对实际项目的分析,展示了防重复提交机制的实际应用。
- 探讨了在实际应用中可能遇到的问题及其解决方案。
标签:实战,存储,Java,String,Redis,Token,提交,public From: https://blog.csdn.net/weixin_68020300/article/details/141260977