分布式限流

1.系统优化应对流量激增的手段

当我们流量激增的时候,为了保持系统的对外高可用,即不能让系统崩溃状态。常用的思路是以下方面:

  • 限流:对应用入口流量做控制,瞬时流量向后迁移,对下游请求流量做自适应限流,根据接口响应时间动态调整流量。
  • 延迟排队:如果请求量大,按业务线优先级排队,优先保障线上渠道实时的请求。(使用MQ削峰)
  • 路由:这个是因为业务的特殊性,所有的请求都依赖下游第三方的服务,可以将多家下游服务供应商做个动态路由表,将请求优先路由给接口成功率高、耗时低的服务供应商;
  • 备份:这基本是所有分布式组件都会做的,能做多机的不做单机,例如:Redis 做三主三备(集群)、MySQL分库分表、MQ 与 Redis 互为备份等等;
  • 降级:这个是最后的迫不得已的措施,如果遇到全线崩溃,使用降级手段保障系统核心功能可用,或让模块达到最小可用。
  • 日志:完整的监控和链路日志,日志功能很多,也分很多种,一方面是方便排查问题,另一方面可用来做任务重试、任务回滚、数据恢复、状态持久化等。

2.限流

限流,顾名思义,就是限制流量,一般分为限制入口流量和限制出口流量,入口流量是人家来请求我的系统,我在入口处加了一道阀门,出口流量是我调外部系统,我在出口加一道阀门。简而言之,就是有一道门,就像你过安检一样,每次只能通过若干的人数。

3.限流的实现方法

3.1 单机的限流实现方法

如果是单机,可以通过Semphore 限制统一时间请求接口的量,也可以用 Google Guava 包提供的限流包
比如:我们现在有5台机器,但是有8个工人;这个时候工人和机器是不对等的。那怎么办呢,那肯定一批一批上啊,先上5个人,然后再让其他3个工人进行操作。下面用代码进行演示

package threadTest;

import java.util.concurrent.Semaphore;

public class Main5 {
    public static void main(String[] args) {
        int n = 8;
        Semaphore semaphore = new Semaphore(5);
        for(int i=0; i<n; i++) {
            new Test(i,semaphore).start();
        }
    }
    static class Test extends Thread{
        private int num;
        private Semaphore semaphore;
        Test(int num, Semaphore semaphore) {
            this.num = num;
            this.semaphore = semaphore;
        }

        @Override
        public void run() {
            try {
                semaphore.acquire();
                System.out.println("工人" + this.num + "占用一个机器在生产......");
                semaphore.release();
                System.out.println("工人" + this.num + "休息去了(释放)......");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
    }
}

image.png

3.2 分布式限流

如果是分布式环境,可以使用 Redis 实现,也有阿里 Sentinal 或 Spring Cloud Gateway 可以实现限流。
其思想和单机是一样的,也是控制资源的访问频率,一般主流的设计思想有二种:
漏洞算法

image.png

把请求比作水,在请求入口和响应请求的服务之间加一个漏桶,桶中的水以恒定的速度流出,这样保证了服务接收到的流量速度是稳定的,如果桶里的水满了,再进来的水就直接溢出(请求直接拒绝)
漏桶是网络环境中流量整形(Traffic Shaping)或速率限制(Rate Limiting)时经常使用的一种算法,它的主要目的是控制数据进入到网络的速率,平滑网络上的突发流量。
令牌桶算法

image.png

令牌桶算法有点类似于生产者消费者模式,专门有一个生产者往令牌桶中以恒定速率放入令牌,而请求处理器(消费者)在处理请求时必须先从桶中获得令牌,如果没有拿到令牌,有二种策略:一种是直接返回拒绝请求,一种是等待一段时间,再次尝试获取令牌
令牌桶算法用来控制发送到网络上的数据的数目,并允许突发数据的发送

3.3 redis实现分布式限流

废话不多说,直接上lua脚本

local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]

local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local fill_time = capacity/rate
local ttl = math.floor(fill_time*2)


local last_tokens = tonumber(redis.call("get", tokens_key)) 

if last_tokens == nil then
  last_tokens = capacity
end

local last_refreshed = tonumber(redis.call("get", timestamp_key)) 

if last_refreshed == nil then
  last_refreshed = 0
end

local delta = math.max(0, now-last_refreshed)  
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))

local allowed = filled_tokens >= requested      
local new_tokens = filled_tokens
local allowed_num = 0
if allowed then
  new_tokens = filled_tokens - requested
  allowed_num = 1
end       

redis.call("setex", tokens_key, ttl, new_tokens)
redis.call("setex", timestamp_key, ttl, now)

return { allowed_num, new_tokens }

PS:这里可能会问,lua 脚本的执行会不会有性能上的损耗,比较redis是单线程的?
redis 使用 epoll 实现I/O多路复用的事件驱动模型,对于每一个读取和写入操作都尽量要快速
可以使用以下方式进行压测:
1.通过script load 命令加载redis lua脚本,得到sha1 之后直接运行

// 1. 在redis服务端load 脚本 拿到sha
redis-cli script load "$(cat ratelimit.lua)"
//sha1: ebbcd2ed99990afaca6d2ba61a0f2d5bdd907e59
// 2. 通过脚本 sha1 值运行脚本
redis-cli evalsha ebbcd2ed99990afaca6d2ba61a0f2d5bdd907e59 2 remain.${0}.tokens last_fill_time 0.2 12 `gdate +%s%3N` 1

2.通过redis客户端的压测工具

redis-benchmark -n 100000 evalsha ebbcd2ed99990afaca6d2ba61a0f2d5bdd907e59 2 remain.${1}.tokens last_fill_time 0.2 12 `gdate +%s%3N` 1

image.png

99.9%都在 2ms以内完成,每秒钟执行4万5千多次,因此损耗可以接受。
SpringBoot实现

3.1 将Lua脚本放在resource

image.png

3.2 工程加载脚本

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;

import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.List;

@Configuration
@Slf4j
public class LuaConfiguration {

    public static final String RATE_LIMIT_SCRIPT_LOCATION = "scripts/redis_limit.lua";

    @Bean(name = "rateLimitRedisScript")
    public DefaultRedisScript<List> redisScript(LettuceConnectionFactory lettuceConnectionFactory) throws UnsupportedEncodingException {
        DefaultRedisScript<List> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(RATE_LIMIT_SCRIPT_LOCATION)));
        String rateLimitSha1 = redisScript.getSha1();
        log.info("分布式限流lua脚本 sha1 :{}",rateLimitSha1);
        log.info("lua脚本 script:{}",redisScript.getScriptAsString());

        List<Boolean> luaScriptsExists;

        RedisConnection redisConnection = lettuceConnectionFactory.getConnection();
        if ((luaScriptsExists = redisConnection.scriptExists(redisScript.getSha1())) != null && luaScriptsExists.size() > 0) {
            log.info("redis 已经存在 redis lua脚本 sha1 :{}",rateLimitSha1);
        } else {
            String scriptLuaSha1 = redisConnection.scriptLoad(redisScript.getScriptAsString().getBytes(StandardCharsets.UTF_8));
            log.info("加载 redis lua 成功 sha1 :{}",scriptLuaSha1);
        }
        return redisScript;
    }

}

3.3 定义加载令牌的工具类

@Component
@Slf4j
public class RateLimiter2 {
    @Autowired
    private RedisLockUtil redisLockUtil;

    @Autowired
    @Qualifier("rateLimitRedisScript")
    private DefaultRedisScript<List> rateLimitRedisScript;
    /**
     * redis集群下;用{1}remain_tokens
     */
    private static final String REDIS_KEY_REMAIN_TOKENS = "remain_tokens";
    private static final String REDIS_KEY_LAST_FILL_TIME = "last_fill_time";

    public boolean achieveDistributeToken(String keySuffix,int tokenCapacity, float tokenGenerateRate,int achiveTokenPer) {
        String remainTokenKey = REDIS_KEY_REMAIN_TOKENS + "_" + keySuffix;
        String lastFillTimeKey = REDIS_KEY_LAST_FILL_TIME + "_" + keySuffix;
        List<String> keys = Arrays.asList(remainTokenKey,lastFillTimeKey);
        Jedis jedis = redisLockUtil.getJedis();
        String now = String.valueOf(System.currentTimeMillis()/1000);
        List<String> args = Arrays.asList(String.valueOf(tokenGenerateRate),String.valueOf(tokenCapacity),now,String.valueOf(achiveTokenPer));
        List<String> result = (List<String>)jedis.eval(rateLimitRedisScript.getScriptAsString(),keys,args);

        if (result != null && result.size() > 0) {
            log.info(">>> 获取分布式令牌是否成功{},接口:{},剩余令牌数量:{}",result.get(0),keySuffix,result.get(1));
            return true;
        }
        return false;
    }
}

3.4 具体使用和效果

image.png
image.png

测试可以使用PostMan的Runner测试



存在的问题:
1.具体到项目的时候,redisTemplate是没法执行脚本的。(原因是,脚本一直报错,报某个参数缺失,进而猜测。【待解决】)
2.Jedis直接执行脚本是没问题的

来源:微信公众号-安琪拉的博客
https://mp.weixin.qq.com/s/dfI9h8bdYgZ60UeByphhYQ

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349