艾编程架构课程第五十三节笔记
分布式幂等性设计
1. 基于本地消息的最终一致性方案设计
- 采用BASE原理实现,保障事务的最终一致性
- 设计的过程中是否采用最终一致性需要根据业务来进行评估
- 基于本地消息的方式,将本事务外的操作,记录在消息表中
- 举例:下订单-支付,这就是两个事务
- 其他事务提供操作接口(支付成功后如果直接调用订单接口)
- 定时轮询的方式将未执行的消息发送给操作接口
- 操作接口返回失败记录失败的标识,需要设置retry次数
- 超过retry的次数后不再进行消息发送并记录我们的失败状态
- 重试后没有成功的就可以通过人工补偿
这个图例的理解:
- 业务表/消息表:支付的业务和支付成功的通知
- 支付相关信息记录到业务表
- 将需要通知订单的消息记录到消息表中
- 定时任务通过定时轮询消息表来获取哪个订单需要发送确认消息
- 定时任务通过接口修改业务表状态
- 如果成功更新消息表状态
- 失败就不断轮询并记录轮询次数,超次数后标记并不再调用
- 业务表:这就是订单信息
- 记录订单业务数据
- 支付成功后订单状态
- 订单的支付状态在一段时间不一致,但最终一致
基于本地消息的特点
- 优点:
- 将事务拆分没有同时操作两个数据库,每一步之操作自己的数据库,保证事务完整性
- 避免了分布式事务,实现最终一致性
- 缺点:
- 要注意重试时的幂等性操作
2. 基于本地消息的最终一致性代码实现
支付的消息表
CREATE TABLE `pay_msg` (
`id` int(11) NOT NULL,
`order_id` int(11) NOT NULL,
`status` int(11) NOT NULL DEFAULT '0',
`fail_count` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
支付的信息表
CREATE TABLE `user_account` (
`id` int(11) NOT NULL,
`username` varchar(255) NOT NULL,
`account` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
订单信息表
CREATE TABLE `order_info` (
`id` int(11) NOT NULL,
`order_status` int(11) NOT NULL,
`order_amount` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
POJO代码
package com.icodingedu.pojo;
import lombok.Data;
@Data
public class PayMsg {
private int id;
private int order_id;
private int status;
private int fail_count;
}
package com.icodingedu.pojo;
import lombok.Data;
@Data
public class UserAccount {
private int id;
private String username;
private int account;
}
package com.icodingedu.pojo;
import lombok.Data;
@Data
public class OrderInfo {
private int id;
private int order_status;
private int order_amount;
}
Mapper代码
package com.icodingedu.mapper.db195;
import com.icodingedu.pojo.PayMsg;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface PayMsgMapper {
int insertPayMs(PayMsg payMsg);
int updatePayMsg(PayMsg payMsg);
List<PayMsg> queryNoSend();
PayMsg queryForId(int order_id);
}
package com.icodingedu.mapper.db195;
import com.icodingedu.pojo.UserAccount;
import org.springframework.stereotype.Repository;
@Repository
public interface UserAccountMapper {
int updateUserAccount(UserAccount userAccount);
UserAccount queryForId(int id);
}
package com.icodingedu.mapper.db197;
import com.icodingedu.pojo.OrderInfo;
import org.springframework.stereotype.Repository;
@Repository
public interface OrderInfoMapper {
int updateOrderInfo(OrderInfo orderInfo);
OrderInfo queryForId(int id);
}
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.icodingedu.mapper.db195.PayMsgMapper">
<insert id="insertPayMs" parameterType="com.icodingedu.pojo.PayMsg">
insert into pay_msg(id,order_id,status,fail_count) values(#{id},#{order_id},#{status},#{fail_count})
</insert>
<update id="updatePayMsg" parameterType="com.icodingedu.pojo.PayMsg">
update pay_msg set status=#{status},fail_count=#{fail_count} where id=#{id}
</update>
<select id="queryNoSend" resultType="com.icodingedu.pojo.PayMsg">
select * from pay_msg where status=0
</select>
<select id="queryForId" resultType="com.icodingedu.pojo.PayMsg">
select * from pay_msg where order_id=#{order_id}
</select>
</mapper>
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.icodingedu.mapper.db195.UserAccountMapper">
<update id="updateUserAccount" parameterType="com.icodingedu.pojo.UserAccount">
update user_account set account=#{account} where id=#{id}
</update>
<select id="queryForId" resultType="com.icodingedu.pojo.UserAccount">
select * from user_account where id=#{id}
</select>
</mapper>
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.icodingedu.mapper.db197.OrderInfoMapper">
<update id="updateOrderInfo" parameterType="com.icodingedu.pojo.OrderInfo">
update order_info set order_status=#{order_status},order_amount=#{order_amount} where id=#{id}
</update>
<select id="queryForId" resultType="com.icodingedu.pojo.OrderInfo">
select * from order_info where id=#{id}
</select>
</mapper>
service
package com.icodingedu.service;
import com.icodingedu.mapper.db197.OrderInfoMapper;
import com.icodingedu.pojo.OrderInfo;
import com.mysql.cj.x.protobuf.MysqlxCrud;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
@Autowired
OrderInfoMapper orderInfoMapper;
/**
*
* @param order_id
* @return:0-更新成功,1-订单不存在
*/
public int handleOrder(int order_id){
OrderInfo orderInfo = orderInfoMapper.queryForId(order_id);
if(orderInfo==null){
return 1;
}
orderInfo.setOrder_status(1);
orderInfoMapper.updateOrderInfo(orderInfo);
return 0;
}
}
package com.icodingedu.service;
import com.icodingedu.mapper.db195.PayMsgMapper;
import com.icodingedu.pojo.PayMsg;
import jdk.internal.dynalink.linker.LinkerServices;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.util.EntityUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class OrderNotifyService {
@Autowired
PayMsgMapper payMsgMapper;
@Scheduled(cron = "0/5 * * * * ?")
public void orderNotify() throws Exception{
System.out.println("进入cron************");
List<PayMsg> payMsgList = payMsgMapper.queryNoSend();
if(payMsgList==null||payMsgList.size()==0){
return;
}
for (PayMsg payMsg: payMsgList) {
int order_id = payMsg.getOrder_id();
//http://localhost:8080/handleorder?id=2001
CloseableHttpClient httpClient = HttpClientBuilder.create().build();
HttpGet httpGet = new HttpGet("http://localhost:8080/handleorder?id="+order_id);
CloseableHttpResponse httpResponse = httpClient.execute(httpGet);
String response = EntityUtils.toString(httpResponse.getEntity());
System.out.println("************调用结果:"+response);
if("success".equals(response)){
payMsg.setStatus(1);
}else{
int count = payMsg.getFail_count();
payMsg.setFail_count(count+1);
if(count+1>5){
payMsg.setStatus(2);
}
}
payMsgMapper.updatePayMsg(payMsg);
}
}
}
package com.icodingedu.service;
import com.icodingedu.mapper.db195.PayMsgMapper;
import com.icodingedu.mapper.db195.UserAccountMapper;
import com.icodingedu.pojo.PayMsg;
import com.icodingedu.pojo.UserAccount;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PaymentService {
@Autowired
UserAccountMapper userAccountMapper;
@Autowired
PayMsgMapper payMsgMapper;
/**
*
* @param uid
* @param order_id
* @param amount
* @return:0-成功,1-用户不存在,2-余额不足
*/
@Transactional(transactionManager = "tm195")
public int payment(int uid,int order_id,int amount){
UserAccount userAccount = userAccountMapper.queryForId(uid);
if(userAccount==null){
return 1;
}
int account = userAccount.getAccount();
if(account<amount){
return 2;
}
userAccount.setAccount(account-amount);
userAccountMapper.updateUserAccount(userAccount);
PayMsg payMsg = new PayMsg();
payMsg.setId(1001);
payMsg.setOrder_id(order_id);
payMsg.setStatus(0);//0-未发送,1-发送成功,2-超次数
payMsg.setFail_count(0);
payMsgMapper.insertPayMs(payMsg);
return 0;
}
}
controller
package com.icodingedu.controller;
import com.icodingedu.service.MsgSendService;
import com.icodingedu.service.PaymentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class PaymentController {
@Autowired
PaymentService paymentService;
@GetMapping("/payment")
@ResponseBody
public String payment(int uid,int orderid,int amount){
int status = paymentService.payment(uid,orderid,amount);
return "支付成功: "+status;
}
}
package com.icodingedu.controller;
import com.icodingedu.service.OrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class OrderController {
@Autowired
OrderService orderService;
@GetMapping("/handleorder")
@ResponseBody
public String orderSet(int id){
try {
int flag = orderService.handleOrder(id);
if (flag == 0) {
return "success";
} else {
return "fail";
}
}catch (Exception ex){
ex.printStackTrace();
return "fail";
}
}
}
Application
package com.icodingedu;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class TccTransactionApplication {
public static void main(String[] args) {
SpringApplication.run(TccTransactionApplication.class, args);
}
}
3. 基于消息队列MQ的最终一致性方案设计
- 原理和流程类似我们的本本地消息
- 不同点
- 本地消息表改为MQ
- 定时任务的职责直接由MQ的消费者来担任了
基于MQ实现最终一致性的问题分析
- 不依赖于定时任务的周期验证,基于MQ更高效、更可靠
- 适合自己企业内部系统调用
- 不同企业的系统之间无法基于MQ,本地消息更适合(第三方应用一般都是通过网络访问)
具体逻辑实现
- 扣减余额后就发送订单更新消息通知给MQ
- 消费端接收消息成功后更新订单状态并返回ACK给MQ
- 如果消费端消费失败则NACK重回队列(回到队首)并记录消费失败次数,超过这个次数就ACK到MQ,然后人工在消息记录里进行补偿
4. 基于消息队列MQ的最终一致性代码实现
POM依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
配置发送和接收
spring:
rabbitmq:
host: 39.100.17.31
port: 5672
username: guest
password: guest
virtual-host: /
connection-timeout: 10000
listener:
simple:
concurrency: 1
max-concurrency: 1
auto-startup: true
prefetch: 1
acknowledge-mode: manual
POJO
package com.icodingedu.pojo;
import lombok.Data;
@Data
public class ReceiveMsg {
private int id;
private int order_id;
private int fail_count;
}
Mapper
package com.icodingedu.mapper.db197;
import com.icodingedu.pojo.ReceiveMsg;
import org.springframework.stereotype.Repository;
@Repository
public interface ReceiveMsgMapper {
int insert(ReceiveMsg receiveMsg);
int update(ReceiveMsg receiveMsg);
ReceiveMsg queryForOrderId(int order_id);
}
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.icodingedu.mapper.db197.ReceiveMsgMapper">
<insert id="insert" parameterType="com.icodingedu.pojo.ReceiveMsg">
insert into receive_msg(id,order_id,fail_count) values(#{id},#{order_id},#{fail_count})
</insert>
<update id="update" parameterType="com.icodingedu.pojo.ReceiveMsg">
update receive_msg set fail_count=#{fail_count} where id=#{id}
</update>
<select id="queryForOrderId" resultType="com.icodingedu.pojo.ReceiveMsg">
select * from receive_msg where order_id=#{order_id}
</select>
</mapper>
controller
package com.icodingedu.controller;
import com.icodingedu.service.MsgSendService;
import com.icodingedu.service.PaymentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class PaymentController {
@Autowired
PaymentService paymentService;
@Autowired
MsgSendService msgSendService;
@GetMapping("/payment")
@ResponseBody
public String payment(int uid,int orderid,int amount){
int status = paymentService.payment(uid,orderid,amount);
msgSendService.sendMessage("msg1001",String.valueOf(orderid));
return "支付成功: "+status;
}
}
service
package com.icodingedu.service;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class MsgSendService {
@Autowired
RabbitTemplate rabbitTemplate;
public void sendMessage(String mid,String msg){
CorrelationData correlationData = new CorrelationData();
correlationData.setId(mid);
rabbitTemplate.convertAndSend("order-exchange","receive.info",msg,correlationData);
}
}
package com.icodingedu.service;
import com.icodingedu.mapper.db197.ReceiveMsgMapper;
import com.icodingedu.pojo.ReceiveMsg;
import com.rabbitmq.client.Channel;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.support.AmqpHeaders;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.Headers;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
public class OrderReceiveService {
@Autowired
OrderService orderService;
@Autowired
ReceiveSendService receiveSendService;
@Autowired
ReceiveMsgMapper receiveMsgMapper;
@RabbitListener(queues = "receive-queue")
@RabbitHandler
public void onOrderMessage(@Payload String orderid, @Headers Map<String,Object> headers, Channel channel) throws Exception{
System.out.println("*********消息收到,开始消费*********");
System.out.println("OrderID :"+orderid);
Long deliverTag = (Long)headers.get(AmqpHeaders.DELIVERY_TAG);
try{
//订单状态发生了改变
int flag = orderService.handleOrder(Integer.valueOf(orderid));
if(flag==0){
channel.basicAck(deliverTag,false);
//通知发送端我接收到消息了
receiveSendService.sendMessage("send1001",orderid);
}else{
ReceiveMsg receiveMsg = receiveMsgMapper.queryForOrderId(Integer.valueOf(orderid));
if(receiveMsg==null){
receiveMsg = new ReceiveMsg();
receiveMsg.setId(3001);
receiveMsg.setFail_count(1);
receiveMsg.setOrder_id(Integer.valueOf(orderid));
receiveMsgMapper.insert(receiveMsg);
channel.basicNack(deliverTag,false,true);
}else{
int fail_count = receiveMsg.getFail_count();
if(fail_count>5){
channel.basicAck(deliverTag,false);
}else{
receiveMsg.setFail_count(fail_count+1);
receiveMsgMapper.update(receiveMsg);
channel.basicNack(deliverTag,false,true);
}
}
}
}catch (Exception ex){
ex.printStackTrace();
ReceiveMsg receiveMsg = receiveMsgMapper.queryForOrderId(Integer.valueOf(orderid));
if(receiveMsg==null){
receiveMsg = new ReceiveMsg();
receiveMsg.setId(3001);
receiveMsg.setFail_count(1);
receiveMsg.setOrder_id(Integer.valueOf(orderid));
receiveMsgMapper.insert(receiveMsg);
channel.basicNack(deliverTag,false,true);
}else{
int fail_count = receiveMsg.getFail_count();
if(fail_count>5){
channel.basicAck(deliverTag,false);
}else{
receiveMsg.setFail_count(fail_count+1);
receiveMsgMapper.update(receiveMsg);
channel.basicNack(deliverTag,false,true);
}
}
}
}
}
package com.icodingedu.service;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class ReceiveSendService {
@Autowired
RabbitTemplate rabbitTemplate;
public void sendMessage(String mid,String msg){
CorrelationData correlationData = new CorrelationData();
correlationData.setId(mid);
rabbitTemplate.convertAndSend("order-exchange","send.confirm",msg,correlationData);
}
}
package com.icodingedu.service;
import com.icodingedu.mapper.db195.PayMsgMapper;
import com.icodingedu.pojo.PayMsg;
import com.rabbitmq.client.Channel;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.support.AmqpHeaders;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.Headers;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
public class SendReceiveService {
@Autowired
PayMsgMapper payMsgMapper;
@RabbitListener(queues = "sendconfirm-queue")
@RabbitHandler
public void onOrderMessage(@Payload String orderid, @Headers Map<String,Object> headers, Channel channel) throws Exception{
System.out.println("===========接收发送确认的消息============");
System.out.println("=====orderid "+orderid);
Long deliverTag = (Long)headers.get(AmqpHeaders.DELIVERY_TAG);
PayMsg payMsg = payMsgMapper.queryForId(Integer.valueOf(orderid));
if(payMsg!=null){
payMsg.setStatus(1);
payMsgMapper.updatePayMsg(payMsg);
channel.basicAck(deliverTag,false);
}
}
}
5. 接口幂等性涉及的相关问题
经常遇到数据重复的问题
- 表单录入如何防止重复提交
- 微服务架构中,客户端重试如何防止重复提交
幂等性:f(f(x)) = f(x)
幂等元素运行多次,还等于它原来运算的结果
什么情况下需要幂等性
重复提交、接口重试、前端业务操作抖动
并不是所有的业务都需要幂等性,要根据实际业务确定是否需要幂等性
6. 保证幂等性的策略分析
保证幂等性的核心思想:通过一个唯一序号保证幂等(如果一个业务操作我们赋予它一个唯一业务id,如果这个业务操作内容一样,那么这个业务id就不变,这个时候有多个一模一样的业务id到后台,就可以进行判断了)
非并发情况下只需要查询这个业务单号是否有操作过,如果没有执行即可
并发情况整个过程用加锁来实现
7. 业务操作的幂等性分析
- Select:查询操作不需要考虑幂等,对数据不会产生任何影响,天然幂等
- Delete:删除操作,第一次修改成功后就不会有任何返回,后续啊不再执行了,也就不用考虑幂等了,但有一个问题,删除操作无论使用什么条件最后都转换到唯一id进行删除
- Update:这个就需要考虑幂等
- 如果更新你的工资,20k,30k,update table set salary=30 where id=1001,这就不需要幂等
- 如果是自增方式:update table set salary+10 where id=1001,需要设计幂等
- Insert:由于是新增操作没有唯一单号,就需要通过token的形式使用幂等
- 混合操作:如果这一组业务有一个唯一单号,就可以使用这个单号进行锁操作,如果没有就需要增肌token进行幂等设计
8. 幂等性的具体设计分析
-
修改操作的幂等设计
- 修改数据前一定是先查询并获得数据了
- 获得的数据里要加上版本号version、update_time
- 修改的时候使用这个version作为条件,如果条件不符更新肯定不成功
- 更新同时变更version
- 实际上就是使用了乐观锁和update行锁实现幂等
-
insert操作的幂等性设计
- 根据唯一单号进行设计:比如限购,一个用于只能购买一个(uid+pid)给这个组合设置唯一索引
- 没有唯一单号:用户提交数据或form表单事重复提交导致的数据重复录入
- 通过token机制来解决
- 在用户打开表单的同时生成一个唯一序列的token,提交数据时将token传递给后台
- 对这个token进行加锁控制,未获得锁的操作全部结束业务
- 为了保证其他重复提交不获得锁,可以不手动释放锁,待其自动超时释放
-
混合操作方式
Delete:删除操作,第一次修改成功后就不会有任何返回,后续啊不再执行了,也就不用考虑幂等了,但有一个问题,删除操作无论使用什么条件最后都转换到唯一id进行删除 -
Update:这个就需要考虑幂等
- 如果更新你的工资,20k,30k,update table set salary=30 where id=1001,这就不需要幂等
- 如果是自增方式:update table set salary+10 where id=1001,需要设计幂等
-
Insert:由于是新增操作没有唯一单号,就需要通过token的形式使用幂等
-
混合操作:如果这一组业务有一个唯一单号,就可以使用这个单号进行锁操作,如果没有就需要增肌token进行幂等设计
8. 幂等性的具体设计分析
-
修改操作的幂等设计
- 修改数据前一定是先查询并获得数据了
- 获得的数据里要加上版本号version、update_time
- 修改的时候使用这个version作为条件,如果条件不符更新肯定不成功
- 更新同时变更version
- 实际上就是使用了乐观锁和update行锁实现幂等
-
insert操作的幂等性设计
- 根据唯一单号进行设计:比如限购,一个用于只能购买一个(uid+pid)给这个组合设置唯一索引
- 没有唯一单号:用户提交数据或form表单事重复提交导致的数据重复录入
- 通过token机制来解决
- 在用户打开表单的同时生成一个唯一序列的token,提交数据时将token传递给后台
- 对这个token进行加锁控制,未获得锁的操作全部结束业务
- 为了保证其他重复提交不获得锁,可以不手动释放锁,待其自动超时释放
-
混合操作方式
- 一整套混合业务操作可以统一使用token机制来加锁进行重复提交限制