高可用电商支付架构设计
在现代电商业务中,支付过程是其中至关重要的一环,一个高可用、安全稳定的支付架构不仅可以提高整个系统的可靠性和扩展性,降低维护成本,还可以优化用户体验,增加用户黏性。
本文将提出一种高可用的电商支付架构设计方案,并全程记录落地方法。
一、前言
1、电商支付一般流程
传统意义上的支付过程是A向B下单商品C,通过渠道D支付成功后,B将商品交给A。
在电商背景下依旧大致沿用这一套流程,只是会进行责任细化,例如上述中的B,会根据业务功能拆分成多个微服务。
每个微服务具有单一的功能,不同微服务之间相互独立运行,呈现一种高内聚、低耦合的状态。
电商支付大致可以划分成如下几个部分。
- 前端:用户通过电脑或手机下订单。
- 订单服务:处理订单并进行库存锁定。
- 支付服务:处理支付请求,和第三方支付平台进行交互。
- 通知服务:支付完成后通知用户和相关系统。
- 外部支付提供商:支付宝等第三方支付平台。
2、本项目业务背景
本项目依托于在线教育平台,平台有在线支付的需求,包括购买课程、购买学习资料、购买教师一对一服务等等,这些购买的对象很明显涉及多个微服务,所以我们需要根据业务功能对电商支付过程进行封装,封装成多个微服务,以便于代码复用。
封装成三个微服务:
- 订单服务:用户点击购买后,请求发送到订单服务,请求中携带参数,包括商品类型、商品id、商品价格、支付渠道等等。
- 支付服务:订单服务需要根据用户选择的支付方式将请求发送到支付服务中,支付服务与第三方的支付平台进行调用。
- 通知服务:在支付完成后,需要将支付结果通知给用户,支付结果的表现形式可以是文字提醒、跳转页面等等。
本项目基于SpringBoot进行开发,默认已经完成了基础服务,例如前端,网关,认证授权,购买对象所属服务(例如课程、资料、教师微服务)。
二、订单服务
用户在前端进行下单,选择商品规则、支付方式等等。
点击支付,请求从前端转发到网关,网关转发到订单服务,经过认证授权后执行业务逻辑。
1、数据库设计
首先,需要对提交的下单请求进行封装处理,保存到数据库。
订单表中的字段包括订单号,订单类型,订单价格,下单用户id,订单支付状态,创建时间,订单描述,第三方微服务唯一id等等。
第三方微服务唯一id是指下单业务涉及到的第三方微服务的唯一id,例如本次下单的是课程服务的商品,那在选课业务中就应当插入一张选课记录表,表中包含字段唯一id,这个id用于避免用户重复下单某件商品。
其中订单号是后台系统中,该订单的唯一标识,在并发分布式系统中,如何生成唯一标识有多种方法。
- 数据库自增长序列或字段生成id,优点:代码简单,成本小,缺点:强依赖DB,数据库迁移时需要考虑DB版本支持问题,只有一个主库可以生成时有单点故障的风险,主从节点切换时可能导致重复发号,难于扩展
- UUID,优点:简单性能高,全球唯一,方便数据迁移合并,缺点:16字节存储成本高,信息不安全,字符串查询效率低,无序,主键插入效率低
- Redis生成Id,Redis生成Id效率比较高,用5台Redis,每个自增步长为5,日期+自增Id作为Id即可,优点:不依赖数据库,Id有序,缺点:Redis单点故障影响可用性,且代码和配置工作量大
- zookeeper生成Id,用zookeeper的znode生成序列号,缺点:在高并发分布式性能差
- 雪花算法snowflake,Twitter开源的分布式Id生成算法,由41位时间戳+10位机器Id+12位毫秒内流水号+1位符号位0组成,优点:稳定灵活递增,缺点:强依赖机器时钟,机器时钟回拨会导致重复发号,分布式不同机器时钟不可能完全同步,导致不是全局递增
这里我们采用雪花算法,对雪花算法的生成实现如下所示
public final class IdWorkerUtils {
private static final Random RANDOM = new Random();
private static final long WORKER_ID_BITS = 5L;
private static final long DATACENTERIDBITS = 5L;
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTERIDBITS);
private static final long SEQUENCE_BITS = 12L;
private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
private static final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
private static final long TIMESTAMP_LEFT_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTERIDBITS;
private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);
private static final IdWorkerUtils ID_WORKER_UTILS = new IdWorkerUtils();
private long workerId;
private long datacenterId;
private long idepoch;
private long sequence = '0';
private long lastTimestamp = -1L;
/**
* @description 构造方法
* @param
* @return
* @author
* @date 2024/6/22 21:03
*/
private IdWorkerUtils() {
this(RANDOM.nextInt((int) MAX_WORKER_ID), RANDOM.nextInt((int) MAX_DATACENTER_ID), 1288834974657L);
}
private IdWorkerUtils(final long workerId, final long datacenterId, final long idepoch) {
if (workerId > MAX_WORKER_ID || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", MAX_WORKER_ID));
}
if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", MAX_DATACENTER_ID));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
this.idepoch = idepoch;
}
/**
* @description 返回雪花算法生成器
* @param
* @return
* @author
* @date 2024/6/22 21:03
*/
public static IdWorkerUtils getInstance() {
return ID_WORKER_UTILS;
}
/**
* @description 生成id
* @param
* @return
* @author
* @date 2024/6/22 21:03
*/
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & SEQUENCE_MASK;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - idepoch) << TIMESTAMP_LEFT_SHIFT)
| (datacenterId << DATACENTER_ID_SHIFT)
| (workerId << WORKER_ID_SHIFT) | sequence;
}
/**
* @description 如果机器时钟回调,自旋直到时钟板正
* @param
* @return
* @author
* @date 2024/6/22 21:13
*/
private long tilNextMillis(final long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
/**
* @description 得到机器时钟
* @param
* @return
* @author
* @date 2024/6/22 21:13
*/
private long timeGen() {
return System.currentTimeMillis();
}
}
在订单表中,我们对商品详情的处理是序列化的方式,一个订单可以包括多个商品,如果后续要对商品进行处理,例如查找所有购买某类商品的用户信息,那就需要更细化的订单商品表。
创建订单商品表,表中包括字段:商品id,订单id,除此以外,还需要包括商品类型、商品名称、商品价格等等。
订单业务还涉及到支付问题,在高并发分布式场景下,应当在用户下订单后及时的将支付信息保存到数据库中,否则会面临重复支付、遗失支付信息的问题,所以在这一节一同设计支付表。
支付记录表是订单业务与支付业务相关联的表,需要包括字段:订单id,支付id,支付交易id,第三方支付渠道以及支付id,订单总金额,下单用户id,支付状态,支付时间,创建时间。
支付id是这条记录的id,而支付交易id是与第三方支付平台交互的id。
2、如何确保订单的幂等性
在保存订单信息到数据库之前,根据订单表中的用户id,和第三方微服务唯一id进行查询,如果可以查到对应记录,就说明该用户已经下单过,直接返回。
需要注意的是,支付记录表无需进行幂等性判定,因为如果支付失败或者用户退出支付页面,则支付交易号失效,这里的失效是指无法重新使用该交易号与第三方支付平台进行交互,需要重新生成一个交易号,但是不能直接修改这条记录,应该作为一条支付失败的记录进行保存。
3、未支付订单如何处理
一个订单在下单后迟迟没有支付,那我们就需要考虑取消该订单,这个时间一般设置为30min,或者1h,可以采用延时队列实现该功能。
在创建队列,或者发送消息时添加超时时间,如果超过这个时间,就将消息投递到队列绑定的死信交换机中,由监听死信队列的消费者执行处理业务。
//创建消息
Message message = MessageBuiider
.withBody(msg)
.setExpiration("1800000")
.build();
//消息id
CorrelationData correlationData = new correlationData(id);
//发送消息
rabbitTemplate,convertAndsend("ttl.direct","ttl",message,correlationData);
三、支付服务
业务背景是pc端,从用户的角度考虑,在用户点击下单后,需要展示一个二维码,用户手机扫码跳转到支付页面进行支付,支付完成后pc端进行相应的提示。
一般市面常用的第三方支付平台是微信支付和支付宝,需要根据用户选择支付类型执行对应支付流程,这个很容易实现,Spring提供了根据String类型的Name依赖注入
@Resource(name="weixin_pay")
private Pay WeixinPay;
//
Object pay = applicationContext.getBean("weixin_pay");
1、支付接口的选择
注意:下面代码中涉及的appid等信息均需要替换成自己申请的信息,仅做演示使用。
1)微信支付
微信支付提供了若干种支付方式
其中:
JSAPI支付是指商户通过调用微信支付提供的JSAPI接口,在支付场景中调起微信支付模块完成收款。
Native支付是指商户系统按微信支付协议生成支付二维码,用户再用微信“扫一扫”完成支付的模式。
这两个方式比较适合pc端支付场景,两个支付流程大同小异,我们选择JSAPI进行详细解释。
在使用之前需要再微信支付平台注册,获得AppID、商户号,并设置回调域名(需要在公网备案)
步骤1-6,用户在前端点击提交订单,后台发送请求到微信支付,生成预付订单,得到预付订单标识
步骤7-11,后台在微信浏览器内通过JSAPI调起支付API调起微信支付,发起支付请求。
步骤12-18,用户支付成功后,商户可接收到微信支付支付结果通知支付结果通知API。
步骤19-22,后台在没有接收到微信支付结果通知的情况下需要主动调用查询订单API查询支付结果。
从上述步骤分析中可以看到,在支付服务中,需要提供JSAPI下单的接口,在传参中需要传入一个异步通知的notify_url,这个url对应后台的一个接口,如果未收到支付结果通知,需要向微信支付查询支付结果。
所以一共需要提供三个接口,分别是JSAPI下单的接口,支付结果异步通知的接口,查询支付结果的接口
JSAPI下单:
//请求URL
HttpPost httpPost = new HttpPost("https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi");
// 请求body参数
String reqdata = "{"
+ "\"amount\": {"
+ "\"total\": 100,"
+ "\"currency\": \"CNY\""
+ "},"
+ "\"mchid\": \"1900006891\","
+ "\"description\": \"Image形象店-深圳腾大-QQ公仔\","
+ "\"notify_url\": \"https://www.weixin.qq.com/wxpay/pay.php\","
+ "\"payer\": {"
+ "\"openid\": \"o4GgauE1lgaPsLabrYvqhVg7O8yA\"" + "},"
+ "\"out_trade_no\": \"1217752501201407033233388881\","
+ "\"goods_tag\": \"WXG\","
+ "\"appid\": \"wxdace645e0bc2c424\"" + "}";
StringEntity entity = new StringEntity(reqdata,"utf-8");
entity.setContentType("application/json");
httpPost.setEntity(entity);
httpPost.setHeader("Accept", "application/json");
//完成签名并执行请求
CloseableHttpResponse response = httpClient.execute(httpPost);
try {
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == 200) {
System.out.println("success,return body = " + EntityUtils.toString(response.getEntity()));
} else if (statusCode == 204) {
System.out.println("success");
} else {
System.out.println("failed,resp code = " + statusCode+ ",return body = " + EntityUtils.toString(response.getEntity()));
throw new IOException("request failed");
}
} finally {
response.close();
httpClient.close();
}
支付结果异步通知的接口:
通过先对post的请求体进行解密,然后反序列化。
微信支付在回调通知中对关键信息进行了AES-256-GCM加密,密钥需要再微信支付控制台进行设置。
/** 商户号 */
public static String merchantId = "";
/** 商户API私钥路径 */
public static String privateKeyPath = "";
/** 商户证书序列号 */
public static String merchantSerialNumber = "";
/** 商户APIV3密钥 */
public static String apiV3Key = "";
public static void main(String[] args) {
// 使用自动更新平台证书的RSA配置
// 一个商户号只能初始化一个配置,否则会因为重复的下载任务报错
Config config =
new RSAAutoCertificateConfig.Builder()
.merchantId(merchantId)
.privateKeyFromPath(privateKeyPath)
.merchantSerialNumber(merchantSerialNumber)
.apiV3Key(apiV3Key)
.build();
JsapiService service = new JsapiService.Builder().config(config).build();
// request.setXxx(val)设置所需参数,具体参数可见Request定义
PrepayRequest request = new PrepayRequest();
Amount amount = new Amount();
amount.setTotal(100);
request.setAmount(amount);
request.setAppid("wxa9d9651ae******");
request.setMchid("190000****");
request.setDescription("测试商品标题");
request.setNotifyUrl("https://notify_url");
request.setOutTradeNo("out_trade_no_001");
Payer payer = new Payer();
payer.setOpenid("oLTPCuN5a-nBD4rAL_fa********");
request.setPayer(payer);
PrepayResponse response = service.prepay(request);
System.out.println(response.getPrepayId());
}
查询支付结果:
//请求URL
URIBuilder uriBuilder = new URIBuilder("https://api.mch.weixin.qq.com/v3/pay/transactions/id/4200000745202011093730578574");
uriBuilder.setParameter("mchid", mchId);
//完成签名并执行请求
HttpGet httpGet = new HttpGet(uriBuilder.build());
httpGet.addHeader("Accept", "application/json");
CloseableHttpResponse response = httpClient.execute(httpGet);
try {
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == 200) {
System.out.println("success,return body = " + EntityUtils.toString(response.getEntity()));
} else if (statusCode == 204) {
System.out.println("success");
} else {
System.out.println("failed,resp code = " + statusCode+ ",return body = " + EntityUtils.toString(response.getEntity()));
throw new IOException("request failed");
}
} finally {
response.close();
}
2)支付宝
支付宝提供了多种支付方式
电脑网站支付提供了两种支付场景,分别是手机支付宝app扫二维码支付,在pc端登录支付宝支付。根据我们的实际需求,可以选择电脑网站支付。
其实手机网站支付和电脑网站支付的后台接口设计方式类似,我们任选其一即可。
步骤1-6,用户进行下单,如果是在pc端登录支付宝,需要输入用户名密码先登录,如果是在pc端用手机app扫码,那直接输入支付密码,确认支付,后台会得到一个同步返回的参数。
步骤7,支付结果通过notify_url异步发送到后台接口。
步骤8,支付宝提供了查询api,通过这个api可以查询支付结果。
从上述步骤分析我们可以发现,后台一共需要实现三个接口,向支付宝发送请求的接口,支付宝异步发送支付结果的接口,向支付宝查询支付结果的接口。
支付宝开发提供了在线的api调试,支持在线以表格填写参数的方式生成请求,以及沙箱app辅助开发。
向支付宝发送请求
public class AlipayTradePagePay {
public static void main(String[] args) throws AlipayApiException {
// 初始化SDK
AlipayClient alipayClient = new DefaultAlipayClient(getAlipayConfig());
// 构造请求参数以调用接口
AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
AlipayTradePagePayModel model = new AlipayTradePagePayModel();
// 设置商户门店编号
model.setStoreId("NJ_001");
// 设置订单绝对超时时间
model.setTimeExpire("2016-12-31 10:05:01");
// 设置业务扩展参数
ExtendParams extendParams = new ExtendParams();
extendParams.setSysServiceProviderId("2088511833207846");
extendParams.setHbFqSellerPercent("100");
extendParams.setHbFqNum("3");
extendParams.setIndustryRefluxInfo("{\"scene_code\":\"metro_tradeorder\",\"channel\":\"xxxx\",\"scene_data\":{\"asset_name\":\"ALIPAY\"}}");
extendParams.setSpecifiedSellerName("XXX的跨境小铺");
extendParams.setRoyaltyFreeze("true");
extendParams.setCardType("S0JP0000");
model.setExtendParams(extendParams);
// 设置订单标题
model.setSubject("Iphone6 16G");
// 设置请求来源地址
model.setRequestFromUrl("https://");
// 设置产品码
model.setProductCode("FAST_INSTANT_TRADE_PAY");
// 设置PC扫码支付的方式
model.setQrPayMode("1");
// 设置商户自定义二维码宽度
model.setQrcodeWidth(100L);
// 设置请求后页面的集成方式
model.setIntegrationType("PCWEB");
// 设置订单包含的商品列表信息
List<GoodsDetail> goodsDetail = new ArrayList<GoodsDetail>();
GoodsDetail goodsDetail0 = new GoodsDetail();
goodsDetail0.setGoodsName("ipad");
goodsDetail0.setAlipayGoodsId("20010001");
goodsDetail0.setQuantity(1L);
goodsDetail0.setPrice("2000");
goodsDetail0.setGoodsId("apple-01");
goodsDetail0.setGoodsCategory("34543238");
goodsDetail0.setCategoriesTree("124868003|126232002|126252004");
goodsDetail0.setShowUrl("http://www.alipay.com/xxx.jpg");
goodsDetail.add(goodsDetail0);
model.setGoodsDetail(goodsDetail);
// 设置商户的原始订单号
model.setMerchantOrderNo("20161008001");
// 设置商户订单号
model.setOutTradeNo("20150320010101001");
// 设置订单总金额
model.setTotalAmount("88.88");
// 设置商户传入业务信息
model.setBusinessParams("{\"mc_create_trade_ip\":\"127.0.0.1\"}");
// 设置优惠参数
model.setPromoParams("{\"storeIdType\":\"1\"}");
request.setBizModel(model);
// 第三方代调用模式下请设置app_auth_token
// request.putOtherTextParam("app_auth_token", "<-- 请填写应用授权令牌 -->");
AlipayTradePagePayResponse response = alipayClient.pageExecute(request, "POST");
// 如果需要返回GET请求,请使用
// AlipayTradePagePayResponse response = alipayClient.pageExecute(request, "GET");
String pageRedirectionData = response.getBody();
System.out.println(pageRedirectionData);
if (response.isSuccess()) {
System.out.println("调用成功");
} else {
System.out.println("调用失败");
// sdk版本是"4.38.0.ALL"及以上,可以参考下面的示例获取诊断链接
// String diagnosisUrl = DiagnosisUtils.getDiagnosisUrl(response);
// System.out.println(diagnosisUrl);
}
}
private static AlipayConfig getAlipayConfig() {
String privateKey = "<-- 请填写您的应用私钥,例如:MIIEvQIBADANB ... ... -->";
String alipayPublicKey = "<-- 请填写您的支付宝公钥,例如:MIIBIjANBg... -->";
AlipayConfig alipayConfig = new AlipayConfig();
alipayConfig.setServerUrl("https://openapi.alipay.com/gateway.do");
alipayConfig.setAppId("<-- 请填写您的AppId,例如:2019091767145019 -->");
alipayConfig.setPrivateKey(privateKey);
alipayConfig.setFormat("json");
alipayConfig.setAlipayPublicKey(alipayPublicKey);
alipayConfig.setCharset("UTF-8");
alipayConfig.setSignType("RSA2");
return alipayConfig;
}
}
支付宝异步发送支付结果,以Post的方式发送请求,在请求中以Map的方式携带参数,需要解码后进行验签处理。
将签名参数(sign)使用 base64 解码为字节码串。
使用 RSA 的验签方法,通过签名字符串、签名参数(经过 base64 解码)及支付宝公钥验证签名。
验签和解码部分的问题可以查看开发文档
Map< String , String > params = new HashMap < String , String > ();
Map requestParams = request.getParameterMap();
for(Iterator iter = requestParams.keySet().iterator();iter.hasNext();){
String name = (String)iter.next();
String[] values = (String [])requestParams.get(name);
String valueStr = "";
for(int i = 0;i < values.length;i ++ ){
valueStr = (i==values.length-1)?valueStr + values [i]:valueStr + values[i] + ",";
}
//乱码解决,这段代码在出现乱码时使用。
//valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
params.put (name,valueStr);
}
//切记alipaypublickey是支付宝的公钥,请去open.alipay.com对应应用下查看。
//boolean AlipaySignature.rsaCheckV1(Map<String, String> params, String publicKey, String charset, String sign_type)
boolean = AlipaySignature.rsaCheckV1(paramsMap, ALIPAY_PUBLIC_KEY, CHARSET, SIGN_TYPE) //调用SDK验证签名
if(signVerified){
// TODO 验签成功后,按照支付结果异步通知中的描述,对支付结果中的业务内容进行二次校验,校验成功后在response中返回success并继续商户自身业务处理,校验失败返回failure
}else{
// TODO 验签失败则记录异常日志,并在response中返回failure.
}
向支付宝查询支付结果
public static void main(String[] args) throws AlipayApiException {
// 初始化SDK
AlipayClient alipayClient = new DefaultAlipayClient(getAlipayConfig());
// 构造请求参数以调用接口
AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
AlipayTradeQueryModel model = new AlipayTradeQueryModel();
// 设置订单支付时传入的商户订单号
model.setOutTradeNo("20150320010101001");
// 设置查询选项
List<String> queryOptions = new ArrayList<String>();
queryOptions.add("trade_settle_info");
model.setQueryOptions(queryOptions);
// 设置支付宝交易号
model.setTradeNo("2014112611001004680 073956707");
request.setBizModel(model);
AlipayTradeQueryResponse response = alipayClient.execute(request);
System.out.println(response.getBody());
if (response.isSuccess()) {
System.out.println("调用成功");
} else {
System.out.println("调用失败");
// sdk版本是"4.38.0.ALL"及以上,可以参考下面的示例获取诊断链接
// String diagnosisUrl = DiagnosisUtils.getDiagnosisUrl(response);
// System.out.println(diagnosisUrl);
}
}
2、支付流程
按照业务背景,用户在前端点击下单后,应该在前端展示二维码,用户用手机扫码后进行支付,展示的二维码应当与用户选择的支付渠道有关。
无论是微信支付还是支付宝支付,都有相似的逻辑,就是由后台向第三方支付平台发送请求,由第三方平台拉起客户端,并同步响应给后台。用户输入密码支付后将支付结果异步发送给后台,同时后台也可以向第三方支付平台查询支付结果。
支付流程分析到这里,还有两个问题,一个是用户扫描的二维码如何生成,其中应该包含哪些信息?一个是支付结果如何保存,在上一章的数据库设计中如何交互?
1)二维码生成
用户选择商品与支付渠道后点击下单,前端应当展示一个二维码,并提示用户用第三方app扫码支付,这个二维码应该包含一个接口,扫码之后将请求发送给第三方平台,第三方平台接收到支付请求后拉起客户端执行支付业务。
在支付服务中创建一个接口,在这个接口中执行向第三方平台发送请求的业务逻辑
@ApiOperation("扫码下单接口")
@GetMapping("/requestpay")
public void requestpay(String payNo, HttpServletResponse httpResponse) throws IOException {
//根据订单id——payNo,查询是否有该订单
//第三方支付平台
}
而二维码中应该包含这个接口的url,生成一张包含对应url信息的二维码,这个实现技术有很多,例如ZXing。
String qrCode = new QRCodeUtil().createQRCode("http://www.xxxx.cn/api/orders/requestpay?payNo=" + payNo, 200, 200);
得到的是一个base64的String的url,返回前端展示,用户扫码后请求发送到后台,后台将支付请求发送到第三方支付平台,在前端拉起第三方客户端进行支付业务逻辑。
2)数据库设计
在接收到支付结果后,需要更新数据库,支付服务的数据库设计已经在上一章中进行了分析。
更新支付记录表,更新字段:支付状态,第三方平台支付交易号,支付结果通知时间
PayRecord payRecord = new PayRecord();
payRecord.setStatus("000002");//支付成功
payRecord.setOutPayNo(payStatus.getTrade_no());//支付交易号
payRecord.setPaySuccessTime(LocalDateTime.now());//通知时间
int update = payRecordMapper.update(payRecord, new LambdaQueryWrapper<PayRecord>().eq(PayRecord::getPayNo, payNo));
更新订单表,更新字段:支付状态,支付完成时间
Orders orders = ordersMapper.selectById(orderId);
orders.setStatus("100002");
orders.setPaySuccessTime(LocalDateTime.now());
int update = ordersMapper.update(orders, new LambdaQueryWrapper<Orders>().eq(Orders::getId, orderId));
之后需要还需要进行消息通知,这个放在下一章:通知服务中讲。
3、支付结果接收失败怎么处理
如果由于网络通信或notify_url设置错误等原因,异步通知支付结果失败,那我们需要手动查询支付结果,第三方支付平台都提供了查询api,问题是我们该以哪种方式查询接口呢?
从用户的角度出发,自然希望支付完成后能及时的得到支付结果的通知,所以在后台向第三方支付平台发起请求后,向消息队列发送一条定时消息,定时结束投递到死信交换机,由监听私信交换机的服务查询数据库,根据支付状态判断是否支付完成,如果是待支付,就根据支付id向第三方渠道查询支付结果,根据查询结果,成功或失败,更新到数据库,如果查询不到结果,或者支付未完成,就将定时消息重新投递到消息队列。
消息队列的设置在下一章讲。
四、通知服务
无论支付成功或失败,都需要对用户进行通知,在支付结果异步通知,或者手动查询支付结果之后,需要进行消息通知。
1、消息队列的选择
常用的消息队列组件一般有三个,RocketMQ、Kafka、RabbitMQ,这三个消息队列各有优缺点,有自己的使用场景,接下来分析三个组件各自使用场景,选择适合我们业务场景的组件。
1、RocketMQ是阿里自研的国产消息队列,它接受来自生产者的消息,将消息分类,每一类是一个 topic,一个topic存放在不同broker下,对应queue和tag,一个broker有一个commitlog,消费者根据需要订阅 topic,获取里面的消息。
2、Kafka由Linkedin公司开发,是一个分布式、支持分区的(partition)、多副本的(replica),基于 zookeeper 协调的分布式消息系统。
3、RabbitMQ是一个开源的消息代理软件,它实现了高级消息队列协议(AMQP),用于在分布式系统之间进行可靠的异步通信。它可以在不同的应用程序、服务和系统之间传递消息,并确保消息的可靠性和顺序性。
RocketMQ和 Kafka 相比,RocketMQ 在架构上做了减法,在功能上做了加法
RabbitMQ 是一个消息代理中间件,支持推送拉取两种模式,Kafka 是一个分布式流处理平台,只支持拉取。
- 对于秒杀活动等吞吐量要求高的,优先选Kafka和RabbitMQ
- 对于要求强对外提供能力,有很多主题介入的,可以考虑千级的RocketMQ,百万级的RabbitMQ
- 金融业务要求稳定安全的,分布式部署Kafka和RocketMQ
我们需要将支付结果通知给用户,这要求了高时效、高可用、支持多主题(提高扩展性,便于其他业务使用),所以我们选择RabbitMQ。
2、消息队列使用方法
首先在docker中启动RabbitMQ容器。
在父工程、或需要使用消息队列的微服务中添加RabbitMQ的依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
在配置文件中添加RabbitMQ的初始化配置
spring:
rabbitmq:
host: xxxxxxxxx
port: xxxx
username: xxxx
password: xxxx
virtual-host: /
publisher-confirm-type: correlated #correlated 异步回调,定义ConfirmCallback,MQ返回结果时会回调这个ConfirmCallback
publisher-returns: false #开启publish-return功能,同样是基于callback机制,需要定义ReturnCallback
template:
mandatory: false #定义消息路由失败时的策略。true,则调用ReturnCallback;false:则直接丢弃消息
listener:
simple:
acknowledge-mode: none #出现异常时返回unack,消息回滚到mq;没有异常,返回ack ,manual:手动控制,none:丢弃消息,不回滚到mq
retry:
enabled: true #开启消费者失败重试
initial-interval: 1000ms #初识的失败等待时长为1秒
multiplier: 1 #失败的等待时长倍数,下次等待时长 = multiplier * last-interval
max-attempts: 3 #最大重试次数
stateless: true #true无状态;false有状态。如果业务中包含事务,这里改为false
我们需要在支付服务中发送消息,让课程服务接收消息并进行处理,交换机Fanout广播模式。
在支付服务中创建交换机
/**
* @author zkp15
* @version 1.0
* @description 消息队列配置
* @date 2023/10/4 22:25
*/
@Configuration
public class PayNotifyConfig {
//交换机
public static final String PAYNOTIFY_EXCHANGE_FANOUT = "paynotify_exchange_fanout";
//支付结果通知消息类型
public static final String MESSAGE_TYPE = "payresult_notify";
//声明交换机
@Bean(PAYNOTIFY_EXCHANGE_FANOUT)
public FanoutExchange paynotify_exchange_fanout() {
// 三个参数:交换机名称、是否持久化、当没有queue与其绑定时是否自动删除
return new FanoutExchange(PAYNOTIFY_EXCHANGE_FANOUT, true, false);
}
}
在课程服务中创建交换机和消息队列,并声明绑定关系
/**
* @author zkp15
* @version 1.0
* @description 消息队列配置
* @date 2023/10/4 22:25
*/
@Configuration
public class PayNotifyConfig {
//交换机
public static final String PAYNOTIFY_EXCHANGE_FANOUT = "paynotify_exchange_fanout";
//通知队列名称
public static final String CHOOSECOURSE_PAYNOTIFY_QUEUE = "choosecourse_paynotify_queue";
//支付结果通知消息类型
public static final String MESSAGE_TYPE = "payresult_notify";
//声明交换机
@Bean(PAYNOTIFY_EXCHANGE_FANOUT)
public FanoutExchange paynotify_exchange_direct() {
// 三个参数:交换机名称、是否持久化、当没有queue与其绑定时是否自动删除
return new FanoutExchange(PAYNOTIFY_EXCHANGE_FANOUT, true, false);
}
//声明队列
@Bean(CHOOSECOURSE_PAYNOTIFY_QUEUE)
public Queue course_publish_queue() {
return QueueBuilder.durable(CHOOSECOURSE_PAYNOTIFY_QUEUE).build();
}
//声明交换机和队列绑定
@Bean
public Binding binding_course_publish_queue(@Qualifier(CHOOSECOURSE_PAYNOTIFY_QUEUE) Queue queue, @Qualifier(PAYNOTIFY_EXCHANGE_FANOUT) FanoutExchange exchange) {
return BindingBuilder.bind(queue).to(exchange);
}
}
接下来由支付服务发送消息到交换机,课程服务监听消息队列,得到消息,进行相应处理。
支付服务发送消息
//根据支付结果更新数据库
......
//向消息队列插入消息
rabbitTemplate.convertAndSend(PayNotifyConfig.PAYNOTIFY_EXCHANGE_FANOUT, "", msg);
课程服务监听消息
@RabbitListener(queues = PayNotifyConfig.CHOOSECOURSE_PAYNOTIFY_QUEUE)
public void receive(String message) {
//获取消息
MqMessage mqMessage = JSON.parseObject(message, MqMessage.class);
//消息类型
String messageType = mqMessage.getMessageType();
//这里只处理支付结果通知
if (PayNotifyConfig.MESSAGE_TYPE.equals(messageType)) {
//获取选课记录id
String choosecourseId = mqMessage.getBusinessKey1();
//添加选课
boolean b = myCourseTablesService.saveChooseCourseStauts(choosecourseId);
if (b) {
//向用户发送付费课程支付成功、选课成功的通知
......
}
}
}
3、如何确保消息的可靠性
如果消息没有通知到用户,用户可能会担心自己是否支付成功,自己的钱去哪里了。
所以要确保消息的可靠性,消息丢失的原因有很多,例如生产者发送消息到消息队列时消息丢失,消息队列宕机了,导致消息全部丢失,消费者在消费消息时由于网络等原因没有消费成功,但是消息队列没有收到通知,就会把消息销毁等等。
RabbitMQ提供了生产者确认机制、消息持久化、消费者确认机制。
生产者确认机制,生产者发送消息到消息队列后,消息队列会响应一个结果给生产者confirm
try {
Channel channel = rabbitTemplate.getConnectionFactory().createConnection().createChannel(false);
channel.confirmSelect();
channel.basicPublish(PayNotifyConfig.PAYNOTIFY_EXCHANGE_FANOUT, "", null, msg);
channel.addConfirmListener(new ConfirmListener() {
//消息失败处理
@Override
public void handleNack(long deliveryTag, boolean multiple) throws IOException {
log.info("sendQueue-ack-confirm-fail");
try {
Thread.sleep(3000l);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//重发
channel.basicPublish(PayNotifyConfig.PAYNOTIFY_EXCHANGE_FANOUT, "", null, msg);
}
//消息成功处理
@Override
public void handleAck(long deliveryTag, boolean multiple) throws IOException {
log.info("sendQueue-ack-confirm-successs");
}
});
} catch (Exception e) {
log.error("发送消息失败——"+e.printStackTrace());
}
消息持久化,消息队列将消息存储在硬盘中,但是这同样会增大开销,导致响应缓慢
我们在声明交换机和消息队列时可以选择持久化
//声明交换机
@Bean(PAYNOTIFY_EXCHANGE_FANOUT)
public FanoutExchange paynotify_exchange_direct() {
// 三个参数:交换机名称、是否持久化、当没有queue与其绑定时是否自动删除
return new FanoutExchange(PAYNOTIFY_EXCHANGE_FANOUT, true, false);
}
//声明队列
@Bean(CHOOSECOURSE_PAYNOTIFY_QUEUE)
public Queue course_publish_queue() {
return QueueBuilder.durable(CHOOSECOURSE_PAYNOTIFY_QUEUE).build();
}
// 消息持久化
Message msg = MessageBuilder
.withBody(message.getBytes(Standardcharsets.UTF_8)) //消息内容
.setDeliveryMode(MessageDeliveryMode.PERSISTENT) //持久代
.build();
消费者确认机制,消费者消费消息后可以选择向MQ发送ack消息,MQ接收到ack后删除消息。
一般情况下MQ会自动发送ack消息,为了确保消息不丢失,我们可以在执行业务逻辑后手动发送ack消息。
try {
// 业务逻辑
......
// deliveryTag消息的index,true表示批量处理所有小于deliveryTag的消息
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (Exception e) {
log.error("消费消息确认失败"+e.printStackTrace());
try {
//手动确认回滚 拒绝deliveryTag对应的消息,第二个参数是否requeue,true则重新入队列,否则丢弃或者进入死信队列。
channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
4、如何避免消息的重复消费
从用户的角度来看,如果通知了两条一模一样的消息,会感觉莫名其妙,可能会怀疑自己充了两次钱,所以有必要研究分析如何避免消息的重复消费。
在Kafka中,同一个partition会维护一个offset,offset表示消息被消费的标记,可以选择手动更新offset来避免消息的重复消费。
而RabbitMQ中,我们采用分布式幂等性解决方案,使用唯一id,或者redis分布式锁。
在业务中,我们采用唯一id,在消息通知时,根据课程id查询是否已经在课程表中,如果存在,就说明用户购买付费课程成功,消息已经通知过了,就不需要重复通知了。
如果用redis分布式锁该如何实现消息不重复消费呢?我们可以在消息通知之前在redis中插入一个分布式锁,锁的内容可以是消息唯一id,这个可以在消息中添加一个id字段来实现,在接收到消息时从redis中查询是否存在这个锁,如果没有,就拒绝消费消息,如果有,就消费消息,并最终删除锁。
五、总结
本文提出了一种高可用的电商支付架构设计方案,依托实际业务场景进行落地开发,架构分为三个模块,订单模块,支付模块和通知模块。
-
在订单模块设计了订单数据库,包括订单表、订单记录表,考虑到了订单的幂等性问题,和订单未能及时处理的问题。
-
在支付模块对常见支付接口进行了选择分析,包括微信支付和支付宝,分析了适合pc端支付的支付流程,并设计了支付记录表,使用二维码生成器生成二维码,成功请求第三方支付平台实现支付,并对支付结果接收失败的问题进行了预备处理。
-
在通知模块使用消息队列RabbitMQ将订单支付结果及时通知给其他微服务,并对消息的可靠性和重复消费问题进行了讨论。
每个模块履行单一职责,模块间耦合度较低,代码复用率高,极大地提升了系统的稳定性,从而实现高可用。
从用户角度出发优化了业务逻辑,提升了用户体验,增强用户黏性,符合电商平台视顾客如上帝的理念。