[转载]数据库锁分布式锁实现接口幂等性

原文:https://mp.weixin.qq.com/s/sR4zUZsv408JDczvAv6_gA

在我们的实际开发中,会由于接口超时后重试机制、MQ的重复消费等场景都会带来接口幂等性的问题,下面我们介绍几种通过锁的方式实现接口幂等性的方案。

1、数据库唯一主键

数据库唯一主键的实现原理是使用数据库中主键唯一约束的特性,一般来说唯一主键比较适用于添加数据时的幂等性场景,唯一主键能保证一张表中只能存在一条该唯一主键的记录,方案的实现的流程如下图所示:

在这里插入图片描述
使用数据库唯一主键实现接口幂等性时需要注意的是,为了保证在分布式环境下ID的全局唯一性,这里的主键一般使用分布式ID充当主键,常见的几种生成分布式ID的方案的整理如下:

整理10种分布式id生成方案

2、数据库悲观锁

假设我们查询到某个订单A后,然后更新订单的状态,此时就可以使用数据库的悲观锁实现。使用悲观锁可以添加for update关键字,即对这行记录上锁(上行锁还是表锁取决于where后面的查询字段索引的类型,假设订单的id是唯一主键,那么就是行锁),如下图所示:
在这里插入图片描述
使用的查询语句如下所示:

select * from order where order_id = 123 for update
若是线程T1执行了select … for update,此时就会对这行记录上锁了,那么直到整个线程T1提交事务之前,T2线程来查询相同的订单id数据会进入到阻塞状态。当T1完成业务处理之后释放锁,其他的线程才可以继续操作这条数据。
悲观锁在同一事务操作过程中锁住了一行数据,别的请求过来只能等待,如果当前事务耗时比较长,就很影响接口性能。对于Mysql数据库,要求存储引擎必须用innodb,因为innodb才支持事务,另外orderId字段一定要是主键或者唯一索引,不然会锁整张表。

3、数据库乐观锁

数据库乐观锁方案一般适用于执行"更新操作"的过程,我们可以提前在对应的数据表中额外添加一个字段来充当数据的版本标识。这样每次对该数据表的这条数据执行更新时,都会将该版本标识作为一个条件,值为上次待更新数据中的版本标识的值,如下图所示的:
在这里插入图片描述
每次更新的时候,我们使用版本号来进行字段校验以及进行update更新,sql如下所示:
UPDATE order SET status=2,version=version+1 WHERE id=123
AND version=0;
在WHERE后面的条件id=123 AND version=0 被执行后,id=123的 version被更新为1,所以如果重复执行该条sql语句将不生效,因为id=123 AND version=0的数据已经不存在,通过这样方式就能保住更新的幂等,多次更新对结果不会产生影响。

4、状态机机制

在业务表中有状态的时候,如订单表中的订单状态(待提交、待支付、待发货、待收货等等)这些状态的值是有规律的,按照业务节点一级级的流转,那么我们就能利用这个特性实现接口的幂等,如下如所示的节点状态流转过程:
在这里插入图片描述

假如订单id为123的订单当前状态是待支付,当用户支付后,订单的状态要变成待发货,更新的语句如下:

update order set status = 2 where order_id = 123 and status = 1
线程T1请求时,该订单的状态是待支付(status = 1),所以该update语句可以正常更新数据,sql执行结果的影响行数是1,订单状态变成了2。
线程T2相同的请求过来,再执行相同的sql时,由于订单状态变成了2,再用status=1作为条件,无法查询出需要更新的数据,所以最终sql执行结果的影响行数是0,即不会真正的更新数据。数据表记录中的状态来作为条件做更新其实跟乐观锁使用版本号类似。

5、分布式锁

唯一主键与乐观锁的本质是使用了数据库的锁,但由于数据库锁的性能不太好,所以我们可使用Redis、Zookeeper等中间件来实现分布式锁的功能,以Redis为例实现幂等的流程图:
在这里插入图片描述
当用户通过浏览器发起请求,服务端接收到请求后,使用唯一标识(如订单号)作为Redis的key。然后使用Redis的set命令,将该唯一标识存储到Redis中并设置超时时间,业务根据Redis返回的结果做不同的处理:
(1)如果保存Redis成功,那么允许当前的线程执行业务操作。
(2)若保存Redis失败,说明是重复请求,则直接响应客户端,请求处理成功。
在分布式锁中要设置一个合理的过期时间,如果设置过短,无法有效的防止重复请求;如果设置过长,占用了Redis的存储空间,在请求非常多的情况下会给Redis造成压力。

总结

每种幂等性实现方案都有各自的优缺点,我们需要根据实际的业务场景选择更合适的方案来解决幂等性问题。

<think>我们有一个需求:使用Java实现请求报文的临时保存、去重以及重新发起请求的功能,并将报文存储在数据库中。 步骤分解: 1. 保存短时间内所有请求报文:我们需要一个机制来捕获所有请求报文,并临时存储。考虑到是临时保存,我们可以设置一个时间窗口(比如最近5分钟)内的请求。 2. 存储到数据库:我们需要设计一个数据库表来存储这些请求报文,同时需要记录请求的时间、去重标识等。 3. 去重:根据一定的规则(比如报文内容、请求URL、参数等)对存储的请求报文进行去重。 4. 重新发起请求:对去重后的请求报文,重新构造请求并发送。 设计思路: 1. 拦截请求:我们可以使用过滤器(Filter)或拦截器(Interceptor)来捕获请求报文。这里以Spring框架为例,使用拦截器。 2. 存储请求报文:将请求报文(包括URL、方法、参数、请求体等)保存到数据库。注意,对于文件上传等请求,需要特殊处理,这里我们假设是普通请求。 3. 去重策略:根据业务需求,可能需要根据请求内容生成一个唯一标识(例如MD5摘要),然后根据这个标识去重。 4. 重新发送:使用HTTP客户端(如RestTemplate、OkHttp等)重新构造请求并发送。 数据库表设计(示例): 表名:request_record 字段: id: 主键(自增) request_url: 请求URL (varchar) request_method: 请求方法 (varchar, 如GET, POST) request_headers: 请求头 (text, 可存储为JSON字符串) request_body: 请求体 (text, 如果是GET则为空) request_time: 请求时间 (datetime) signature: 请求签名(用于去重)(varchar, 唯一索引) status: 状态(用于标记是否已重新发送,0-未发送,1-已发送)(int) 注意:由于请求报文可能较大,我们需要根据实际情况调整字段类型。 去重策略: 我们可以对请求方法、URL、参数和请求体进行组合,然后生成一个MD5摘要作为签名(signature)。这样,相同的请求会生成相同的签名,从而实现去重。 重新发送请求: 我们可以定时任务或者手动触发一个任务,从数据库中读取状态为未发送的记录,按照去重(相同签名只取一条)后,重新发送请求。 代码实现步骤: 1. 创建拦截器捕获请求: - 在拦截器的preHandle或afterCompletion中,我们可以获取请求信息。但是注意,获取请求体需要特别注意,因为流只能读取一次,我们可以通过包装请求(使用ContentCachingRequestWrapper)来解决。 2. 保存请求到数据库: - 将请求信息封装为一个实体对象,然后存入数据库。 3. 去重重新发送的调度任务: - 使用Spring的定时任务(@Scheduled)定期处理未发送的请求,或者提供一个API接口手动触发。 4. 重新发送请求: - 使用RestTemplate发送HTTP请求。 下面我们逐步实现: 第一步:创建拦截器,保存请求报文 由于需要读取请求体,我们使用ContentCachingRequestWrapper包装原始请求。 创建拦截器: ```java import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class RequestCaptureInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 将请求包装为可重复读取的请求 ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request); // 注意:这里需要调用一次getInputStream()或getReader()来缓存内容,否则后续拦截器或控制器无法获取请求体 wrappedRequest.getInputStream().readAllBytes(); // 将请求体读入缓存 // 将包装后的请求放入请求属性,以便后续使用(或者在这里保存请求信息) request.setAttribute("cachedRequest", wrappedRequest); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 在请求完成后,从属性中获取包装后的请求 ContentCachingRequestWrapper wrappedRequest = (ContentCachingRequestWrapper) request.getAttribute("cachedRequest"); if (wrappedRequest != null) { // 保存请求报文到数据库 saveRequestToDB(wrappedRequest); } } private void saveRequestToDB(ContentCachingRequestWrapper request) { // 获取请求信息 String url = request.getRequestURL().toString(); String method = request.getMethod(); String headers = ...; // 获取请求头,可以转换为JSON字符串 byte[] bodyBytes = request.getContentAsByteArray(); String body = new String(bodyBytes, request.getCharacterEncoding()); // 生成签名:对URL、方法、参数、请求体进行MD5(这里注意,参数包括查询字符串和请求体,但通常POST请求体包含参数,GET请求参数在URL中) // 这里简化:将URL(包含查询字符串)、方法、请求体组合后生成MD5 String signature = generateSignature(url, method, body); // 构建RequestRecord对象 RequestRecord record = new RequestRecord(url, method, headers, body, signature, new Date(), 0); // 保存到数据库(这里省略数据库操作,使用JPA或MyBatis等) requestRecordRepository.save(record); } private String generateSignature(String url, String method, String body) { String data = url + "|" + method + "|" + body; // 使用MD5生成摘要 return DigestUtils.md5DigestAsHex(data.getBytes()); } } ``` 注意:需要注册拦截器,并且要包装请求,所以还需要一个过滤器来包装请求,因为拦截器无法改变请求对象。 因此,我们还需要一个过滤器: ```java import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; public class ContentCachingFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 包装请求 ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request); filterChain.doFilter(requestWrapper, response); } } ``` 然后,在配置类中注册过滤器和拦截器。 第二步:创建实体类RequestRecord和Repository(以Spring Data JPA为例) ```java @Entity @Table(name = "request_record") public class RequestRecord { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String requestUrl; private String requestMethod; @Column(columnDefinition = "TEXT") private String requestHeaders; @Column(columnDefinition = "TEXT") private String requestBody; private String signature; private Date requestTime; private Integer status; // 0:未发送,1:已发送 // 构造方法、getter和setter省略 } ``` ```java public interface RequestRecordRepository extends JpaRepository<RequestRecord, Long> { // 根据签名和状态查找 List<RequestRecord> findBySignatureAndStatus(String signature, Integer status); } ``` 第三步:定时任务去重并重新发送 ```java import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component public class RequestResendTask { @Autowired private RequestRecordRepository requestRecordRepository; @Scheduled(fixedRate = 60000) // 每分钟执行一次 public void resendRequests() { // 查询所有状态为0(未发送)的记录 List<RequestRecord> records = requestRecordRepository.findByStatus(0); // 根据签名去重:相同签名只保留一个(这里我们取id最小的一个) Map<String, RequestRecord> uniqueRecords = new HashMap<>(); for (RequestRecord record : records) { uniqueRecords.compute(record.getSignature(), (key, existingRecord) -> { if (existingRecord == null || record.getId() < existingRecord.getId()) { return record; } return existingRecord; }); } // 遍历去重后的记录,重新发送 for (RequestRecord record : uniqueRecords.values()) { try { resendRequest(record); // 发送成功后,将该签名对应的所有记录状态更新为已发送(或者只更新当前记录?根据需求) // 这里我们只更新当前记录 record.setStatus(1); requestRecordRepository.save(record); } catch (Exception e) { // 发送失败,记录日志,可以设置重试次数等 } } } private void resendRequest(RequestRecord record) { // 使用RestTemplate发送请求 RestTemplate restTemplate = new RestTemplate(); HttpMethod method = HttpMethod.valueOf(record.getRequestMethod()); String url = record.getRequestUrl(); // 设置请求头 HttpHeaders headers = new HttpHeaders(); // 将请求头字符串转换为Map(这里假设我们存储的是JSON字符串) Map<String, String> headerMap = parseHeaders(record.getRequestHeaders()); for (Map.Entry<String, String> entry : headerMap.entrySet()) { headers.add(entry.getKey(), entry.getValue()); } // 设置请求体 HttpEntity<String> requestEntity = new HttpEntity<>(record.getRequestBody(), headers); // 发送请求 ResponseEntity<String> response = restTemplate.exchange(url, method, requestEntity, String.class); // 处理响应... } private Map<String, String> parseHeaders(String headersJson) { // 使用JSON解析库(如Jackson)将JSON字符串转换为Map // 这里简化,假设headersJson是JSON字符串 ObjectMapper mapper = new ObjectMapper(); try { return mapper.readValue(headersJson, new TypeReference<Map<String, String>>(){}); } catch (Exception e) { return Collections.emptyMap(); } } } ``` 注意:上述代码中,去重策略是保留相同签名中id最小的一条,然后重新发送,并将该条记录状态更新为已发送。这样,其他相同签名的记录仍然保持未发送状态,在下次任务执行时,由于去重(取id最小的,而我们已经发送了最小id的那条)就不会再发送了。但是这样会导致重复记录一直留在数据库。我们可以考虑在发送成功后,将所有相同签名的记录都标记为已发送。 修改resendRequests方法中的发送成功后的处理: ```java // 发送成功后,将该签名对应的所有记录状态更新为已发送 List<RequestRecord> sameSignatureRecords = requestRecordRepository.findBySignature(record.getSignature()); for (RequestRecord r : sameSignatureRecords) { if (r.getStatus() == 0) { r.setStatus(1); requestRecordRepository.save(r); } } ``` 这样,同一个签名的所有请求记录都会被标记为已发送。 另外,我们还需要考虑请求报文的存储时间,可以设置一个定时任务清理过期的请求记录(比如超过1小时的记录)。 总结: 1. 通过过滤器和拦截器捕获请求报文并存储到数据库。 2. 使用定时任务对未发送的请求进行去重(根据签名)并重新发送。 3. 发送成功后更新状态。 注意事项: - 对于文件上传等请求,上述方法可能不适用,因为请求体是二进制数据,需要特殊处理。 - 生成签名时,如果请求头对业务有影响,也应该纳入签名生成。 - 重新发送请求时,需要根据实际请求方法(GET、POST等)和请求头(如Content-Type)正确构造请求。 - 在高并发场景下,需要考虑数据库压力,以及拦截器保存请求报文对性能的影响。 相关问题: 1. 如何避免拦截器保存请求报文对性能的影响? 2. 除了MD5,还有哪些去重策略? 3. 如何设计一个更通用的请求重发框架? 4. 在分布式环境中,如何保证去重的正确性(多个实例同时处理)? 5. 如何确保请求重新发送的幂等性?</think>### Java实现请求报文的临时保存、去重与重发 #### 一、解决方案设计 1. **核心流程** ```mermaid graph LR A[请求拦截] --> B[报文存储] B --> C[定时去重处理] C --> D[重新发起请求] D --> E[结果反馈] ``` 2. **技术组件** - **请求拦截**:Servlet Filter / Spring Interceptor - **报文存储**:MySQL + JDBC / JPA - **去重处理**:Redis布隆过滤器 / 数据库唯一索引 - **重发请求**:RestTemplate / Apache HttpClient --- #### 二、关键代码实现 1. **请求拦截与存储(Filter示例)** ```java public class RequestCaptureFilter implements Filter { private RequestRecordDAO dao; // 数据库操作类 @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper((HttpServletRequest) request); chain.doFilter(wrappedRequest, response); // 获取请求报文 String requestBody = new String(wrappedRequest.getContentAsByteArray(), StandardCharsets.UTF_8); // 生成唯一签名(MD5摘要) String signature = DigestUtils.md5DigestAsHex(requestBody.getBytes()); // 存储到数据库 RequestRecord record = new RequestRecord( UUID.randomUUID().toString(), requestBody, signature, new Date(), RequestStatus.PENDING ); dao.save(record); } } ``` 2. **数据库表设计** ```sql CREATE TABLE request_record ( id VARCHAR(36) PRIMARY KEY, request_body TEXT NOT NULL, signature VARCHAR(32) UNIQUE, -- 去重标识 create_time DATETIME NOT NULL, status ENUM('PENDING', 'PROCESSED') NOT NULL ); ``` 3. **定时去重与重发** ```java @Scheduled(fixedRate = 30000) // 每30秒执行 public void processRequests() { List<RequestRecord> pendingRequests = dao.findByStatus("PENDING"); // 使用布隆过滤器去重(需引入Guava) BloomFilter<String> filter = BloomFilter.create(Funnels.stringFunnel(), 10000); for (RequestRecord record : pendingRequests) { if (!filter.mightContain(record.getSignature())) { filter.put(record.getSignature()); // 重新发起请求 ResponseEntity<String> response = restTemplate.postForEntity( "https://target-service/api", record.getRequestBody(), String.class ); if (response.getStatusCode().is2xxSuccessful()) { record.setStatus(RequestStatus.PROCESSED); dao.update(record); } } } } ``` --- #### 三、性能优化建议 1. **缓存层优化** - 使用Redis替代内存布隆过滤器,避免服务重启丢失数据 - 设置签名过期时间:`redisTemplate.opsForValue().set(signature, "", 5, TimeUnit.MINUTES)` 2. **并发控制** ```java // 使用分布式锁(Redisson实现) RLock lock = redisson.getLock("request-process-lock"); if (lock.tryLock(10, TimeUnit.SECONDS)) { try { // 处理请求 } finally { lock.unlock(); } } ``` 3. **存储优化** - 大报文处理:压缩请求体 `GZIPOutputStream` - 分区存储:按时间分表(request_record_202405) --- #### 四、注意事项 1. **幂等性设计** - 重发接口需支持等(如使用`X-Request-ID`头) - 响应去重:记录已处理请求的响应结果[^3] 2. **异常处理** - 设置最大重试次数(3次) - 死信队列存储失败请求 3. **资源清理** ```sql -- 定时清理旧数据 DELETE FROM request_record WHERE create_time < NOW() - INTERVAL 1 HOUR; ``` --- ### 相关问题 1. 如何防止请求报文存储导致的数据库性能瓶颈? 2. 分布式环境下如何保证去重操作的原子性? 3. 除了MD5,还有哪些高效的请求去重算法? 4. 如何设计报文重发机制的消息确认(ACK)? 5. 在微服务架构中如何实现跨服务的请求重发? [^1]: 一级缓存:基于 PerpetualCache 的 HashMap 本地缓存,它的声明周期是和 SQLSession 一致的,有多个 SQLSession 或者分布式的环境中数据库操作,可能会出现脏数据。当 Session flush 或 close 之后,该 Session 中的所有 Cache 就将清空,默认一级缓存是开启的。 [^2]: Java面试题总结。今天同学写个手机游戏,用蓝牙传输数据的时候丢包,问我解决方案,我提出的方案是:用多线程发送数据并要求对方回送ack号,如果在一定时间内没收到就要重发,如果收到了就要自身wait,那么这要用到多线了,开始写了几个老是报错,就在网上找了找这方面的资料,终于解决了,呵呵!下面我把这篇写的比较全面的文章转载过来,做个笔记,希望能帮助更多的用多线程出现问题的朋友们。 [^3]: 尽量使用 tryLock(long timeout, TimeUnit unit)的方法(ReentrantLock、ReentrantReadWriteLock),设置超时时间,超时可以退出防止死。尽量使用 Java. util. concurrent 并发类代替自己手写。尽量降低的使用粒度,尽量不要几个功能用同一把。尽量减少同步的代码块。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值