这是《百图解码支付系统设计与实现》专栏系列文章中的第(14)篇。点击上方关注,深入了解支付系统的方方面面。
本篇主要介绍分布式场景下常用的并发流量控制方案,包括固定时间窗口、滑动时间窗口、漏桶、令牌桶、分布式消息中间件等,并重点讲清楚固定时间窗口应用原理和应用场景,以及使用reids实现的核心代码。
在非支付场景,也常常需要用到这些并发流量控制方案。
在互联网应用里面,并发流量控制无所不在。在支付系统中,流量控制同样是一个关键的技术方面,主要用于确保系统的稳定性和可靠性,尤其在高流量的情况下。以下是一些主要使用流量控制的场景:
特别说明的是,流量控制通常包括限流和限速。
限流:就是流量达到一定程度,超过的流量会全部立即拒绝掉,也就是快速失败。比如上面的API限流。
限速:一般是指接收流量后,先保存到队列中,然后按指定的速度发出去,如果超过队列最大值,才会拒绝。比如上面的支付流量和退款流量打到外部渠道。
另外,支付和退款流量控制虽然都是流量控制,但有一些细小的区别:
固定窗口:算法简单,对突然流量响应不够灵活。超过流量的会直接拒绝,通常用于限流。
滑动窗口: 算法简单,对突然流量响应比固定窗口灵活。超过流量的会直接拒绝,通常用于限流。
漏桶算法:在固定窗口的基础之上,使用队列缓冲流量。提供了稳定的流量输出,适用于对流量平滑性有严格要求的场景。后面会介绍如何应用到外部渠道退款场景。
令牌桶算法:在滑动窗口的基础之上,使用队列缓冲流量。能够允许一定程度的突发性流量,但实现较为复杂。
分布式消息中间件:如Kafka和RabbitMQ等,能够有效地对消息进行缓冲和管理,增加系统复杂性,且如果需要精确控制流量还需要引入额外的机制。后面会介绍如何应用到外部渠道支付场景。
固定窗口算法,也称为时间窗口算法,是一种流量控制和速率限制策略。此算法将时间轴分割成等长、不重叠的时间段,称为“窗口”。每个窗口都有一个独立的计数器,用于跟踪窗口期间的事件数量(如API调用、数据包传输等)。
固定窗口算法的好处是简单,缺点也很明显,就是无法应对突发流量,比如每秒30并发,如果前100ms来了30个请求,那么在10ms内就会把30个请求打出去,后面的900ms的请求全部拒绝。
工作流程:
主要用于简单的限流。比如在渠道网关做限流,发送渠道的请求最大不能超过测算出来的值,避免渠道侧过载,可能会导致支付请求批量失败。
是有损服务的一种实现方式。
为什么选择redis?因为在分布式场景下,限流需要有一个集群共用的计算数来保存当前时间窗口的请求量,redis是一个比较优的方案。
场景示例:WPG渠道的支付每秒不能超过20TPS。
那么设计key=“WPG-PAY” + 当前时间戳(精确到S),数据过期时间为2S(这个过期时间主要是兼容各服务器的时间差)。
下面是流程图:
lua脚本:limit.lua
local key = KEYS[1]
-- 默认为2S超期,精确到S级。也可以改造成由外面传进来 --
local expireTime = 2
-- 先自增,如果不存在就自动创建 --
redis.incr(key);
local count = tonumber(redis.call("get", key))
-- 如果结果为1,说明是新增的,设置超时时间 --
if count == 1 then
redis.call("expire", key, expireTime)
end
return count;
redis操作类:RedisLimitUtil
/**
* redis限流操作类
*/
@Component
public class RedisLimitUtil {
// 限流脚本
private static final String LIMIT_SCRIPT_LUA = "limit.lua";
@Autowired
private RedisTemplate redisTemplate;
private DefaultRedisScript limitScript;
/**
* 缓存脚本
*/
@PostConstruct
public void cacheScript() {
limitScript = new DefaultRedisScript();
limitScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(LIMIT_SCRIPT_LUA)));
limitScript.setResultType(Long.class);
List cachedScripts = redisTemplate.getConnectionFactory().getConnection().scriptExists(
limitScript.getSha1());
// 需要缓存
if (CollectionUtils.isEmpty(cachedScripts) || !cachedScripts.get(0)) {
redisTemplate.getConnectionFactory().getConnection().
scriptLoad(redisTemplate.getStringSerializer().serialize(limitScript.getScriptAsString()));
}
}
/**
* 判断是否限流
* 这里不考虑超过long最大值的情况,系统在达到long最大值前就奔溃了。
*/
public boolean isLimited(String key, long countLimit) {
Long count = redisTemplate.execute(limitScript, Lists.newArrayList(key));
return countLimit >= count;
}
}
使用:PayServiceImpl
/**
* 支付服务示例
*/
public class PayServiceImpl implements PayService {
@Autowired
private RedisLimitUtil redisLimitUtil;
@Override
public PayOrder pay(PayRequest request) {
if (isLimited(request)) {
throw new RequestLimitedException(buildExceptionMessage(request));
}
// 其它业务处理
... ...
}
/*
* 限流判断
*/
private boolean isLimited(PayRequest request) {
// 限流KEY,这里以[业务类型 + 渠道]举例
String key = request.getBizType() + request.getChannel();
// 限流值
Long countLimit = countLimitMap.get(key);
// 如果key对应的限流值没有配置,或配置为-1,说明不限流
if (null == countLimit || -1 == countLimit) {
return false;
}
return redisLimitUtil.isLimited(key + buildTime(), countLimit);
}
}
注释写得比较清楚,没有什么需要补充的。
分布式流控有很多实现方案,使用redis实现的固定时间窗口是最简单的方案,而且也非常实用,应付一般的场景已经足够使用。
下一篇会介绍滑动时间窗口算法及实现。
支付系统设计与实现是一个专业性非常强的领域,里面涉及到的很多设计思路和理论也可以应用到其它行业的软件设计中,比如幂等性,加解密,领域设计思想,状态机设计等。
在《百图解码支付系统设计与实现》的知识宇宙,每一篇深入浅出的文章都是一颗既独立但又彼此强关联的星球,有必要提供一个传送门以便让大家即刻到达想要了解的文章。
专栏地址:百图解码支付系统设计与实现
领域相关:
支付行业黑话:支付系统必知术语一网打尽
跟着图走,学支付:在线支付系统设计的图解教程
支付交易的三重奏:收单、结算与拒付在支付系统中的协奏曲
在线支付系统的精英搭档:深入剖析收银核心与支付引擎的协同作战(一)
在线支付系统的精英搭档:深入剖析收银核心与支付引擎的协同作战(二)
技术专题:
交易流水号的艺术:掌握支付系统的业务ID生成指南
揭密支付安全:为什么你的交易无法被篡改
金融密语:揭秘支付系统的加解密艺术
支付系统日志设计完全指南:构建高效监控和问题排查体系的关键基石
避免重复扣款:分布式支付系统的幂等性原理与实践
支付系统的心脏:简洁而精妙的状态机设计与核心代码实现
精确掌控并发:分布式环境下并发流量控制的设计与实现(一)
精确掌控并发:分布式环境下并发流量控制的设计与实现(二)
金融疆界:在线支付系统渠道网关的创新设计(一)