通路数控制设计与实现

序言
通路作为系统资源的一种,代表系统并发的能力。“通路数”指标直观反映系统并发支持服务能力。例如,在外呼场景中,系统支持电话同时通话数;在视频对话场景中,系统支持同时视频在线人数。众所周知,互联网系统都具有“三高”特性(高可用 H-availability、高扩展 H-scailablity、高性能 H-proficient),而并发控制归属于高性能的一种情况。

作者将按以下顺序梳理,读者可以按需观看

1. 通路数的含义,通路数与QPS的关系
2. 通路数控制目标
3. 两种通路数控制的方式,基于分布式锁、基于单线程模型,及源码解析
4. 经验总结

1、通路数含义

  • 通路数:系统同时承载任务数
  • QPS:系统平均每秒响应数

你可能会疑问了,这两个数不应该相等么?其实还真不相等,任务是需要时间执行的,可能远远大于1秒。
举例:拨打电话,每个电话60秒,若系统是60秒打完60个电话,且第60秒电话同时挂断,则通路数在第60秒是60,而系统在这60秒的QPS是1。

  • 理论QPS = 通路数 / 单个电话时长(秒)
  • 实际QPS = 总响应数量 / 使用时长(秒)
  • 举例:
  • 200通路,每个电话平均时长5秒,那么发起呼叫的理论QPS=200/5=40
  • 50000个号码,使用1小时拨打完,实际QPS=50000/60/60=13.8

2、通路数控制目标

通路数控制也被称为并发控制,这涉及两个概念,通路数上限值、已占通路数。
通路上限值:系统同时并发服务能力上限

  • 一般受限于后端的应用承载能力,例如智能对话场景中,受限于ASR、TTS、NLU等集群的能力

已占用通路数:系统当前已支持并发服务数

  • 租户每发起一次调用,该指标加1
  • 租户每次调用,系统完成服务,该指标减1
  • 到达上限通路数后,系统要提示租户已到上限,拒绝服务请求
  • 系统完成所有调用服务后,已占通路数归0

3、通路数控制方案(源码解析)

我将带你看两种通路数控制的调度方案,并对其性能作出归纳,在介绍之前,作者默认读者已经熟悉Redis的使用、单线程模型、lua脚本。
一般来说,我们有新的轮子,就不要自己造轮子。方案1是自己造轮子,方案2是使用已有的轮子,而使用lua脚本替代Redis锁,可极大提升系统性能,建议读者多实践使用。
方案1:redis锁+原生的原子性操作

加减操作如下:

@Service
@Slf4j
// 调度小模型
public class CapacityHandleDemo {
    String lock = "LOCK";
    // 总通路数Capacity,已占通路数current
    int capacity = 50;
    @Autowired
    private RedisCacheManager redisCacheManager;

    // key为一个用户ID,该方法给用户已占通路数+1;若到达上限,则不再+1,直接返回false
    public boolean add(String key) {
        if (capacity <= 0) {
            return false;
        }
        // 获取分布式锁
        if (!lock(lock)) {
            log.info("not lock");
            return false;
        }
        try {
            // 若不存在,则说明已占通路数为0,直接+1
            if (!redisCacheManager.exists(key)) {
                Long current = redisCacheManager.incr(key);
                log.info("add after is:[{}]", current);
                return true;
            }
            Long current = Long.valueOf(redisCacheManager.getStr(key));
            log.info("add before-:[{}]", current);
            // 到上限,则不再+1
            if (current >= capacity) {
                return false;
            }
            // 存在且不到上限,+1
            current = redisCacheManager.incr(key);
            log.info("add after-:[{}]", current);
            Thread.sleep(1000);
        } catch (Exception e) {
            log.info("e", e);
            return false;
        } finally {
            this.releaseLock(lock);
        }
        return true;
    }

    // key为一个用户ID,该方法给用户已占通路数-1;若到达下限0,则不再-1,直接返回false
    public boolean sub(String key) {
        if (!lock(lock)) {
            log.info("not lock");
            return false;
        }
        try {
            // 若不存在,则说明已占通路数为0,直接返回false
            if (!redisCacheManager.exists(key)) {
                log.info("key is not exist:[{}]", key);
                return false;
            }
            Long current = Long.valueOf(redisCacheManager.getStr(key));
            log.info("sub before:[{}]", current);
            // 到下限,则不再-1
            if (current <= 0) {
                log.info("key is under 0:[{}]", key);
                return false;
            }
            // 存在且不到下限,直接减1
            current = redisCacheManager.decr(key);
            log.info("sub after:[{}]", current);
            Thread.sleep(1000);
        } catch (Exception e) {
            log.info("e,", e);
            return false;
        } finally {
            this.releaseLock(lock);
        }
        return true;

    }

    private boolean lock(String lock) {
        // 获取100*1秒
        for (int i = 0; i < 100; i++) {
            if (redisCacheManager.getLock(lock)) {
                return true;
            }
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                log.info("e", e);
            }
        }
        return false;
    }

    private void releaseLock(String lock) {
        redisCacheManager.releaseLock(lock);
    }

}

调用主函数如下:

@Slf4j
public class BaseServiceTest extends DemoApplicationTests {

    @Autowired
    protected RedisCacheManager redisCacheManager;

    // 调度小模型
    @Autowired
    private CapacityHandleDemo capacityHandle;

    private int threadCapacity = 5;

    private ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(threadCapacity,
            threadCapacity, 0, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(100));

    @Test
    public void testIncr() throws Exception {
        String key = "VALUE";
        redisCacheManager.del(key);
        for (int i = 0; i < 10; i++) {
            threadPoolExecutor.execute(() -> capacityHandle.add(key));
            log.info("addT " + threadPoolExecutor);
        }
        // 确保加法都做完了
        // 因为某些线程对于CPU的抢占能力强,避免一直是他执行,导致后面的减法即使加入队列后,提前执行了
        Thread.sleep(10000);
        Future<Boolean> subFuture = null;
        for (int i = 0; i < 10; i++) {
            subFuture = threadPoolExecutor.submit(() -> capacityHandle.sub(key));
            log.info("subT " + threadPoolExecutor);
        }
        System.out.println("subResult:" + subFuture.get());
        Thread.sleep(1000);
        System.out.println(redisCacheManager.getStr(key));
    }
}

预期效果:一直加到10,然后减会0

方案2:lua脚本
加减模型如下:

@Service
@Slf4j
// 调度小模型
public class CapacityHandleDemoLua {

    int capacity = 50;
    @Autowired
    private RedisCacheManager redisCacheManager;

    // key为一个用户ID,该方法给用户已占通路数+1;若到达上限,则不再+1,直接返回false
    public boolean add(String key) {
        if (capacity <= 0) {
            return false;
        }
        String script = "if redis.call('EXISTS',KEYS[1]) == 1 then\n" +
                "   local value=redis.call('GET', KEYS[1])  \n" +
                "   if tonumber(value) >= tonumber(ARGV[1]) then\n" +
                "      return -1 \n" +
                "   else\n" +
                "      return redis.call('INCR', KEYS[1])\n" +
                "   end  \n" +
                "else  \n" +
                "   redis.call('INCR', KEYS[1])\n" +
                "end";
        Object object = redisCacheManager.useLuaScript(Arrays.asList(key),
                Arrays.asList(String.valueOf(capacity)), script);
        if (object == null) {
            log.error("add tenant capacity error, key:{}", key);
            return false;
        }
        if ((long) object == -1L) {
            log.warn("add tenant capacity error, key:{}", key);
            return false;
        }
        return true;
    }

    // key为一个用户ID,该方法给用户已占通路数-1;若到达下限0,则不再-1,直接返回false
    public boolean sub(String key) {
        String script = "if redis.call('EXISTS',KEYS[1]) == 1 then\n" +
                "   local value=redis.call('GET', KEYS[1])  \n" +
                "   if tonumber(value) <= 0\n" +
                "      then     \n" +
                "         return -1 \n" +
                "     end  \n" +
                "   return  redis.call('DECR', KEYS[1])\n" +
                "   else  \n" +
                "   return -1\n" +
                "end";
        Object object = redisCacheManager.useLuaScript(Arrays.asList(key), Lists.newArrayList(), script);
        if (object == null) {
            log.error("sub capacity error, key:{}", key);
            return false;
        }
        if ((long) object == -1L) {
            log.warn("sub capacity error, key:{}", key);
            return false;
        }
        log.debug("sub after:[{}], userId:[{}]", redisCacheManager.getStr(key), key);
        return true;
    }

}

主函数调用如下:

@Slf4j
public class BaseServiceTest extends DemoApplicationTests {

    @Autowired
    protected RedisCacheManager redisCacheManager;
    
    @Autowired
    private CapacityHandleDemoLua capacityHandleDemoLua;
    private int threadCapacity = 5;

    private ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(threadCapacity,
            threadCapacity, 0, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(100));

    @Test
    public void testIncrLua() throws Exception {
        String key = "VALUE";
        redisCacheManager.del(key);
        for (int i = 0; i < 10; i++) {
            threadPoolExecutor.execute(() -> capacityHandleDemoLua.add(key));
            log.info("addT " + threadPoolExecutor);
        }
        // 确保加法都做完了
        // 因为某些线程对于CPU的抢占能力强,避免一直是他执行,导致后面的减法即使加入队列后,提前执行了
        Thread.sleep(10000);
        log.info("add after:" + redisCacheManager.getStr(key));
        Future<Boolean> subFuture = null;
        for (int i = 0; i < 10; i++) {
            subFuture = threadPoolExecutor.submit(() -> capacityHandleDemoLua.sub(key));
            log.info("subT " + threadPoolExecutor);
        }
        System.out.println("subResult:" + subFuture.get());
        Thread.sleep(1000);
        System.out.println("sub after:" + redisCacheManager.getStr(key));
    }

预期效果:一直加到10,然后减到0

在这里插入图片描述
附带Redis客户端实现代码:


    @Autowired
    private RedisConfig redisConfig;

    @Autowired
    private JedisCluster jedisCluster;
    
    public Object useLuaScript(List<String> keys, List<String> argv, String script) {
        if (CollectionUtils.isEmpty(keys)) {
            log.error("key is missed !,key:{},argv:{},script:{}", keys, argv, script);
            return null;
        }
        try {
            // 加上环境前缀
            List<String> targetKeys =
                    keys.stream().map(s -> redisConfig.getAppPrefix() + s.trim()).collect(Collectors.toList());
            log.info("targetKeys:{}", targetKeys);
            return jedisCluster.eval(script, targetKeys, argv);
        } catch (Exception e) {
            log.error("CacheManager decrUntilLower error!", e);
            return null;
        }
    }

测试基准类代码

@SpringBootTest
@RunWith(SpringJUnit4ClassRunner.class)
public class DemoApplicationTests {
    private int threadCapacity = 5;

    public ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(threadCapacity,
            threadCapacity, 0, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(100));

    @Before
    public void init(){
        MockitoAnnotations.initMocks(this);
    }

}

4、经验总结

文章末尾,来一波学习方法的总结:
特别有感触,学习一件复杂的事情共通的方法是,先做分解,刻意重复训练,最后当你连贯使用时,就是你掌握的时刻。准确的分解依赖于指导与总结,训练依赖于耐力,连贯使用依赖于顿悟。任何一环出现问题,都会导致你样样都会,但样样不精,刷题如此,健身如此,打篮球打网球游泳滑雪亦如此

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值