0%

微信API-Gateway流控和缓存功能解析

简介

随着WXBot的微服务化,在WXBot后端实际上独立出了数十个微服务,并且微服务的数量在持续上升。这些微服务目前日均调用量有数千万到数亿次。 这些微服务都需要有鉴权、流控、缓存和监控等功能。我们希望将这些功能放在API-Gateway中统一实现。同时因为目前QPS在一万左右,该API-Gateway的响应性能也至关重要。

因为API-Gateway本质上要解决的问题是一个高IO,高并发,低计算的工作。Go的性能不错,且开发效率相对高,人力储备也相对充足,因此最终决定使用Golang开发API-Gateway。

目前接入了API-Gateway的微服务有机器翻译,QA,分词,NER(命名实体识别),知识图谱,文章分类等服务。

架构解析

api-gateway

整个API-Gateway是基于调用链的,会链式调用Auth,Rate Limit, Size Limit,Cache(可选),Reverse Proxy等组件。

Auth的实现目前基于ConfigMap,我们将用户信息全部配置在ConfigMap里,原因是因为我们部署基于Kubernetes,所以可以非常简单的广播配置文件。又因为,我们的用户或者需要持久化的数据,显然不会非常多(例如,用户数估计短期内不会上千)。而用文件做持久化,速度更快,开发更简单。更容易保证不掉QPS的要求。

SizeLimit本身没有什么好说的,如果Request的Size超出了预定的Size就直接拒绝请求。

Reverse Proxy目前支持三种逻辑,RoundRobin、Random和User-hash:

  1. RoundRobin方式会在后端的backend上顺序依次转发请求
  2. Random方式在后端的backend里随机取一个转发请求
  3. User-hash方式根据request里的User name hash出一个hash值来选定后端backend,这种方式可以保证相同用户的请求可以一直转发到后端的同一个backend

Rate Limit和Cache的实现方式会在后文详细说明。

流控功能

限流算法

限流算法一般来说有以下几种:

  1. 漏桶算法

  2. 令牌桶算法

漏桶(Leaky Bucket)算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率。因为漏桶的漏出速率是固定的参数,所以,即使网络中不存在资源冲突(没有发生拥塞),漏桶算法也不能使流突发(burst)到端口速率。因此,漏桶算法对于存在突发特性的流量来说缺乏效率。

令牌桶算法(Token Bucket)和 Leaky Bucket 效果一样但方向相反的算法,更加容易理解。随着时间流逝,系统会按恒定1/QPS时间间隔(如果QPS=100,则间隔是10ms)往桶里加入Token(想象和漏洞漏水相反,有个水龙头在不断的加水),如果桶已经满了就不再加了,新请求来临时,会各自拿走一个Token,如果没有Token可拿了就阻塞或者拒绝服务。

多后端限流池

限流的工程本质是多个分布式计数器字典:

  • Key: 是需要限流的特征Key。
    • 比如 : 某一个API,某一个用户,某一个IP下,每分钟限流100次,那么Key可以是 {API}_{USER}_{IP}
  • Value: 是目前的已调用次数,或者剩余的可调用次数。

所以,简单的用 Key做一个哈希,可以很容易的分派给多后端。

实现

我们最后采取了基于Redis的限流算法:

  • Key: {API与用户融合的字符串}_{当前分钟数}
  • Value: 目前的调用次数。
1
2
3
4
5
key = f"{key}_{min}"
call_count = redis.incr(key)
if call_count >= limit:
return LimitExceeded
return OK

举个例子,比如我们使用的Key为zA21X31,过期时间设为59秒,限流速率为20 Req/min。

Redis Key zA21X31:0 zA21X31:1 zA21X31:2(超限) zA21X31:3 zA21X31:4(超限)
Value 3 8 20 >2 20
Expires at Latest 12:02 Latest 12:03 Latest 12:04 Latest 12:05 Latest 12:06
Time 12:00 12:01 12:02 12:03 12:04

简单来说就是使用Redis计算每分钟的单个用户在特定api发送的Request数,如果少于limit就计数,如果大于等于limit就拒绝该请求的服务。

我们的算法类似于漏桶算法,使用令牌桶算法能够可以比较平滑的限流,但是考虑到有单点故障的隐患(比如增加计数的进程挂了),以及部署起来略微复杂,所以采用了当前算法。

针对部分服务的优化

像机器翻译服务这类的服务,每个request包含的翻译语句长度不一,而后端翻译服务处理请求的时间和句子的长度成正相关的关系,因此不能简单地以每分钟的Request数来限流。

所以针对机器翻译服务,我们将计数器每次加的值改成了HTTP Request中payload数据成员的长度,payload即用户希望翻译的句子经过base64编码的字符串。

这种解决方式给Request存在权重问题的情景给出了一种统一的解决方案。从广泛的角度来说,我们可以把这种Request不平均的问题抽象成一个权重,在机器翻译服务是句子的长度,在别的场景可能是另外的权重,可以由后端服务的开发者来定义,计数器每次加这样一个权重值来进行限流以解决不均衡问题。

缓存功能

动机

以机器翻译服务为例,其本身是个幂等性的服务,即翻译前后语句的对应关系保持不变,因此可以考虑将此映射存在缓存里,缓存里有的话直接从缓存里拿,没有的话再去请求后端服务,为后端服务减轻负担,并且同时可以减少Request的响应时间,提高性能。

实现

我们采用了基于Redis的Key-Value对来存放映射关系:

  • Key:对于每个服务的每个句子有一个独立的key,考虑到用户针对不同场景可能需要不同的缓存,因此添加了一个extrakey字段给用户自己定义
    • apiName+extraKey(用户自定义字段)+Payload(翻译之前的句子)
  • Value:翻译之后的句子

其实实现很简单,可以用伪代码表示如下:

1
2
3
4
5
6
7
8
Key = Serialize(apiName,extraKey,Payload)
if Key in cache:
Value = Get(Key)
refresh TTL
else
Value = Get Result from backend
Set(Key, Value) # can put it in Goroutine
return Deserialize(Value)

其中Cache不命中之后,再往缓存存key-value对的逻辑,不影响主逻辑的进行,因此可以放入Go协程中进行,可以压缩掉一个请求存数据库的时间。

考虑到Redis是基于内存的存储,使用起来成本比较高,因此我们实现了基于Redis和SSDB的两种版本。SSDB是一个高性能的支持丰富数据结构的 NoSQL 数据库,用于替代 Redis。它能够达到Redis的100倍容量,并且兼容了Redis的API。它将热点数据放在内存中,其余的数据放在硬盘。

几个需要注意的问题

缓存的命中率,缓存命中率与TTL有关,TTL的时间越长则缓存的命中率越高,但是相对来说就越占空间。因此选择一个合适的过期时间是比较tricky的事情,要在后续的实际应用中验证。目前我们的缓存过期时间设定的是7天。

缓存的失效,比如后端机器翻译服务的model更新了,在目前的实现里缓存的数据并不会实时失效或者更新。一种解决方式是设定一个trigger,每次更新model之后自动将缓存清空。

SSDB的Key有长度限制,需要小于等于100。这个问题可以用hash去解决,把Key映射为一个hash值来解决这个问题,但是会存在hash碰撞的情况,如果hash函数设置的足够好,碰撞的概率是极其低的。另外也有几种解决思路,比如从业务需求的角度来讲,Key的长度大于100是不是合理的,长度不符合要求的请求是可以在size limit那层过滤掉的。除此之外也可以重新编译SSDB的源码,改动它对Key的长度限制,以使之符合要求。

总结

我们对API-Gateway中基于Redis的限流算法做了详细的说明,并且针对特定的后端服务做了一层缓存,实现了基于Redis和SSDB的两个版本。限流和缓存这两块的功能已经稳定,未来应该不会再有大的变化。本工作在josephyu的指导下进行,感谢josephyu。

微信机器翻译服务向Ctranslate2框架的迁移过程

简介

Ctranslate2是维护OpenNMT模型的公司新出的一种专门针对OpenNMT-py和OpenNMT-tf模型的优化推理引擎,同时支持CPU和GPU。这里的优化主要指两个方面,一是模型的压缩,二是推理的加速。

Ctranslate2具有以下关键特性:

  • 高效运行
  • 交互式解码
  • 模型量化
  • 并行翻译
  • 动态内存使用
  • 自动化的指令集调度
  • 轻量的磁盘占用
  • 易于使用的翻译API

我们之前机器翻译服务是使用的OpenNMT模型,因此可以很简单的迁移到Ctranslate2上。Ctranslate2的使用方式也很简单,主要分为两步:

  1. 转模型,将OpenNMTPy或者OpenNMTf模型转换为该框架支持模型的二进制形式,量化参数支持int8和int16,也可不做量化操作。

    以下是将一个OpenNMTPy模型转换成Ctranslate2 int8量化模型的示例:

    1
    2
    3
    4
    5
    6
    import ctranslate2
    converter = ctranslate2.converters.OpenNMTPyConverter(cfg.model)
    output_dir = converter.convert(output_dir="./vx_model",
    model_spec=transformer_spec,
    quantization=int8,
    force=True)
  2. 使用转换后的模型进行翻译。

    1
    2
    3
    import ctranslate2
    translator = ctranslate2.Translator("vx_model/")
    translator.translate_batch([["你好", "世界", "!"]])

分析

CTranslate2的核心实现与框架无关。特定于框架的逻辑移至转换步骤,该步骤将训练好的模型序列化为简单的二进制格式。它是以这种方式来兼容不同机器学习框架的model。

其次我觉得需要回答这样一个问题,Ctranslate2的翻译效果为什么快?我觉得关键之处在于”定制”,普遍意义上的神经网络框架比如Pytorch和TensorFlow是针对所有任务的,但是Ctranslate2只是针对机器翻译服务的,可以做一些针对性的优化。

CTranslate2的整体架构可以分为这么几个部分:

  1. 通用层

    • 模型格式:模型格式定义了每个model中variable的表示

    • 模型序列化:转二进制格式

  2. C++ engine

    • 存储:Ctranslate2使用行优先的存储方式,定义在StorageView Class
    • 抽象层
      • primitives:底层计算函数
      • ops
      • layers
      • models
      • translators:使用model实现文本翻译逻辑的高阶类
      • translators pool:并行计算的translator池,共享同一个model
    • Ops

主要影响Ctranslate2运行速度的有两个参数,分别是intra_threadsinter_threads

  • intra_threads是每次转换使用的线程数:增加此值可减少延迟。
  • inter_threads是并行执行的最大翻译引擎的数量:增加此值以增加吞吐量(由于线程内部的某些内部缓冲区被复制,这也会增加内存使用量)。

我们可以从源码角度看一下这两个参数是在哪个地方起作用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
TranslatorPool(size_t num_replicas, size_t num_threads_per_replica, Args&&... args) {
set_num_threads(num_threads_per_replica);
_translator_pool.emplace_back(std::forward<Args>(args)...);
// On GPU, we currently don't benefit much from running instances in parallel, even
// when using separate streams. This could be revisited/improved in the future.
if (_translator_pool.back().device() == Device::CUDA)
num_replicas = 1;
for (size_t i = 1; i < num_replicas; ++i)
_translator_pool.emplace_back(_translator_pool.front());
for (auto& translator : _translator_pool)
_workers.emplace_back(&TranslatorPool::work_loop,
this,
std::ref(translator),
num_threads_per_replica);
}

num_replicas即inter_threads,num_threads_per_replica即intra_threads。

所以inter_threads其实决定了TranslatorPool的大小,即翻译引擎的个数。

1
2
3
4
5
6
void set_num_threads(size_t num_threads) {
#ifdef _OPENMP
if (num_threads != 0)
omp_set_num_threads(num_threads);
#endif
}

而intra_threads决定了同一个翻译任务起多少个线程去翻译。

因此在实际部署中,我们采用了inter_threads数为1,intra_threads数等于核数的方案。

实现

机器翻译服务现在可以分为这么几个阶段:

  • 中英翻译流程:分词,bpe,翻译,delbpe,detruecase,detokenize
  • 英中翻译流程:normalize, tokenize, subEntity,转小写,bpe,翻译,delbpe,去空格

替换过程只需要将翻译阶段中的predictor替换成ctranslator2的实现即可,但要注意Ctranslate2框架下的输入和之前的机器翻译服务有些许不同,需要改动一下bpe阶段的输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def load_predictor(config_file):
# model config
cfg = token_process_tools.TokenProcessor(config_file)
if with_onmt_py:
quantize_dynamic = "with quantize_dynamic" if with_quantize_dynamic else "not with quantize_dynamic"
print("With ONMT ", quantize_dynamic)
translator = base_model.get_onmt_translator(cfg, with_quantize_dynamic)
return base_model.BatchPredictor(cfg, translator, debug)
else:
print("With ctranslate2 ", ctranslate2_quantization)
translator = base_model.get_ctranslate2_translator(
cfg,
ctranslate2_quantization,
inter_threads=inter_threads,
intra_threads=intra_threads)
return base_model.BatchPredictorWithCtranslate2(cfg, translator, debug)


predictcn = load_predictor('./model/cn2en_config.yml')
predicten = load_predictor('./model/en2cn_config.yml')

上线

目前还未正式上线,在TKE集群上部署进行小流量测试。因为量化模型会对翻译效果造成一定的影响,因此针对Ctranslate2框架训练的model非常重要。未来会在测试充分的情况下,使用auto-serve正式上线。

性能

我们对CTranslate2机器翻译框架替换前后的机器翻译成本做了对比:

测试的机器是2核的机器,每个model测试10个句子,token数目统一是3834。

时间 QPS 处理1M token的时间(h) 处理1M token的成本(元)
长句子(V2[onmt,无量化]) 75.33 0.1327 5.4579 0.5542
长句子(V2[onmt,有量化]) 38.46 0.2600 2.7865 0.2829
长句子(V3[onmt,无量化]) 181.69 0.0550 13.1640 1.3367
长句子(V3[onmt,有量化]) 95.07 0.1051 6.8879 0.6994
长句子(V4[onmt,无量化]) 149.83 0.0667 10.8557 1.1023
长句子(V4[onmt,有量化]) 77.40 0.1291 5.6082 0.5695
长句子(V4[ctrans2,fp32]) 139.53 0.0716 10.1094 1.0265
长句子(V4[ctrans2,int16]) 102.13 0.0979 7.4001 0.7514
长句子(V4[ctrans2,int8]) 70.26 0.1423 5.0910 0.5169

可以看出替换成Ctranslate2的机器翻译框架,在int8的量化情况下要比ONMT的量化模型节省了9.6%的成本。

总结和展望

我们基于Ctranslate2这种全新的机器翻译框架实现了机器翻译服务上的predict模块,翻译的性能有了一些提升,可以节省下一些机器的成本。未来会对model进行针对Ctranslate2框架进一步的调优,并且会进行更加充分的测试。

关于进一步的调优工作,如果是在GPU上进行的机器翻译服务,可以对CUDA caching allocator的一些参数进行针对性的调优,如bin_growth,min_bin,max_bin和max_cached_bytes等参数。

这个工作在josephyu和florianzhao指导下进行,感谢他们。