序言
通路作为系统资源的一种,代表系统并发的能力。“通路数”指标直观反映系统并发支持服务能力。例如,在外呼场景中,系统支持电话同时通话数;在视频对话场景中,系统支持同时视频在线人数。众所周知,互联网系统都具有“三高”特性(高可用 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、经验总结
文章末尾,来一波学习方法的总结:
特别有感触,学习一件复杂的事情共通的方法是,先做分解,刻意重复训练,最后当你连贯使用时,就是你掌握的时刻。准确的分解依赖于指导与总结,训练依赖于耐力,连贯使用依赖于顿悟。任何一环出现问题,都会导致你样样都会,但样样不精,刷题如此,健身如此,打篮球打网球游泳滑雪亦如此