Go流量控制 系列文章目录 结合自己在工作中使用nacos+go-sentinel实现动态流量控制
第一篇会介绍一下Nacos的部署,服务端,客户端的原理以及Nacos一致性协议Raft
Nacos介绍
Nacos的适用场景涵盖了许多领域,其中一些包括:
- 数据库连接信息管理:Nacos可以用于管理数据库连接信息,使得应用程序可以动态地获取最新的数据库连接信息,从而实现数据库连接的动态管理和配置。
- 限流规则和降级开关:通过Nacos,可以动态地管理限流规则和降级开关的配置,以应对系统在高负载或异常情况下的流量控制需求,保障系统的稳定性和可靠性。
- 流量的动态调度:Nacos可以用于动态调整流量的分发策略和配置,根据实时的业务需求和系统负载情况,灵活地调整流量的分配,实现流量的动态调度和负载均衡。
在Nacos 1.X架构中,配置中心的推送功能通过长轮询构建,周期性地由客户端主动发送HTTP请求并在发生更新时返回变更内容;而服务注册中心的推送则通过UDP推送+HTTP定期对账来实现。
然而,配置中心的长轮训、服务注册中心的定期对账,都需要周期性地对于服务端进行一次主动建连和配置传送,增大服务端的内存开销;随着Nacos用户的服务数和配置数规模的增大,服务端的内存泄漏风险也大大增加。
为了更好的支撑用户的性能要求,克服HTTP短连接架构固有的性能瓶颈,Nacos社区进行了一次基于长连接的重构升级。长连接时代的Nacos2.x在原本1.x的架构基础上新增了对gRPC长连接模型的支持,同时保留对旧客户端和OpenAPI的兼容。
通信层目前通过gRPC实现了长连接RPC调用和推送能力。升级完成之后,服务变化、配置变更等信息会通过gRPC的双向流主动推送给客户端 ,而客户端只需要针对各个长连接主动发送轻量级的心跳即可。升级后的技术架构极大地减少了服务端处理数据的开销;同时,由于长连接基于可复用TCP的机制,也大大降低了网络堵塞的风险。
牛逼完了就该部署了
部署单机Nacos
docker 拉取镜像
注意:官方镜像中以 slim 结尾支持 arm64 架构
yaml
docker pull nacos/nacos-server:v2.1.2-slim
创建容器
yaml
docker run --env PREFER_HOST_MODE=hostname --env MODE=standalone --env NACOS_AUTH_ENABLE=true -p 8848:8848 -p 9848:9848 -p 9849:9849 -d nacos/nacos-server:v2.1.2-slim
注:这里三个端口一个要对应,不然可能会出现连接不上的问题
进入监管界面
[http://ip:8848/nacos\] (服务器部署进不去记得检查一些防火墙,三个端口都放开)
注:初始账户密码均为 nacos,只要进入了页面就代表启动成功了
4、修改密码:为安全起见,建议及时修改密码
自己玩玩可以单机,生产怎么能单机🐶
集群部署Nacos
部署架构
- 一般来说,在生产环境上,我们需要实现Nacos高可用
- 方案包括多节点反向代理,多节点Nacos,高可用Mysql
Nacos作为配置中心的集群结构中,是一种无中心化节点的设计,由于没有主从节点,也没有选举机制,所以为了能够实现热备,就需要增加虚拟IP(VIP)。
Nacos的数据存储分为两部分
- Mysql数据库存储,所有Nacos节点共享同一份数据,数据的副本机制由Mysql本身的主从方案来解决,从而保证数据的可靠性。
- 每个节点的本地磁盘,会保存一份全量数据,具体路径:
/data/program/nacos-1/data/config-data/${GROUP}
.
在Nacos的设计中,Mysql是一个中心数据仓库,且认为在Mysql中的数据是绝对正确的。 除此之外,Nacos在启动时会把Mysql中的数据写一份到本地磁盘。
这么设计的好处是可以提高性能,当客户端需要请求某个配置项时,服务端会想Ian从磁盘中读取对应文件返回,而磁盘的读取效率要比数据库效率高。
当配置发生变更时:
- Nacos会把变更的配置保存到数据库,然后再写入本地文件。
- 接着发送一个HTTP请求,给到集群中的其他节点,其他节点收到事件后,从Mysql中dump刚刚写入的数据到本地文件中。
另外,NacosServer启动后,会同步启动一个定时任务,每隔6小时,会dump一次全量数据到本地文件
docker-compose
- nacos docker-compose 7848为http端口,nacos配置文件以环境变量nacos-embedded.env配置
- hostname: 修改hostname为主机ip
- 挂载日志到 /data/software/nacos/cluster-logs/
yaml
version: "3"
services:
nacos1:
hostname:
container_name: nacos
image: nacos
volumes:
- /data/software/nacos/logs/:/home/nacos/logs
- /data/software/nacos/application.properties:/home/nacos/conf/application.properties
ports:
- "7848:7848"
- "8848:8848"
- "9848:9848"
- "9849:9849"
env_file:
- ./nacos-embedded.env
restart: always
application.properties
yaml
# spring
server.servlet.contextPath =${SERVER_SERVLET_CONTEXTPATH: / nacos}
server.contextPath = / nacos
server.port =${NACOS_APPLICATION_PORT: 8848}
server.tomcat.accesslog.max - days = 30
server.tomcat.accesslog.pattern =
server.tomcat.accesslog.enabled =${TOMCAT_ACCESSLOG_ENABLED: false}
server.error.include - message = ALWAYS
# default current work dir
server.tomcat.basedir = file:.
# *************** Config Module Related Configurations ***************#
### Deprecated configuration property, it is recommended to use `spring.sql.init.platform` replaced.
# spring.datasource.platform=${SPRING_DATASOURCE_PLATFORM:}
spring.sql.init.platform =${SPRING_DATASOURCE_PLATFORM:}
nacos.cmdb.dumpTaskInterval = 3600
nacos.cmdb.eventTaskInterval = 10
nacos.cmdb.labelTaskInterval = 300
nacos.cmdb.loadDataAtStart = false
db.num =${MYSQL_DATABASE_NUM: 1}
db.url= jdbc:mysql: // ${MYSQL_SERVICE_HOST}:${MYSQL_SERVICE_PORT: 3306} /${MYSQL_SERVICE_DB_NAME}?${MYSQL_SERVICE_DB_PARAM: characterEncoding = utf8 & connectTimeout = 1000 & socketTimeout = 3000 & autoReconnect = true & useSSL = false}
db.user=${MYSQL_SERVICE_USER}
db.password=${MYSQL_SERVICE_PASSWORD}
### The auth system to use, currently only 'nacos' and 'ldap' is supported:
nacos.core.auth.system.type =${NACOS_AUTH_SYSTEM_TYPE: nacos}
### worked when nacos.core.auth.system.type=nacos
### The token expiration in seconds:
nacos.core.auth.plugin.nacos.token.expire.seconds =${NACOS_AUTH_TOKEN_EXPIRE_SECONDS: 18000}
### The default token:
nacos.core.auth.plugin.nacos.token.secret.key =${NACOS_AUTH_TOKEN:}
### Turn on/off caching of auth information. By turning on this switch, the update of auth information would have a 15 seconds delay.
nacos.core.auth.caching.enabled =${NACOS_AUTH_CACHE_ENABLE: false}
nacos.core.auth.enable.userAgentAuthWhite =${NACOS_AUTH_USER_AGENT_AUTH_WHITE_ENABLE: false}
nacos.core.auth.server.identity.key =${NACOS_AUTH_IDENTITY_KEY:}
nacos.core.auth.server.identity.value =${NACOS_AUTH_IDENTITY_VALUE:}
## spring security config
### turn off security
nacos.security.ignore.urls =${}
# metrics for elastic search
management.metrics.export.elastic.enabled = false
management.metrics.export.influx.enabled = false
nacos.naming.distro.taskDispatchThreadCount = 10
nacos.naming.distro.taskDispatchPeriod = 200
nacos.naming.distro.batchSyncKeyCount = 1000
nacos.naming.distro.initDataRatio = 0.9
nacos.naming.distro.syncRetryDelay = 5000
nacos.naming.data.warmup = true
management.endpoints.web.exposure.include = *
nacos-embedded.env
shell
PREFER_HOST_MODE=hostname
EMBEDDED_STORAGE=embedded
NACOS_SERVERS=ip1,ip2,ip3
SPRING_DATASOURCE_PLATFORM=mysql
MYSQL_SERVICE_HOST=xxxxxxxxx
MYSQL_SERVICE_DB_NAME=nacos_config
MYSQL_SERVICE_PORT=3306
MYSQL_SERVICE_USER=xxx
MYSQL_SERVICE_PASSWORD=xxx
NACOS_AUTH_ENABLE=true
NACOS_AUTH_IDENTITY_KEY=xxxxxxxx
NACOS_AUTH_IDENTITY_VALUE=xxxxxxxxxxx
NACOS_AUTH_TOKEN=SecretKey012345678901234567890123456789012345678901234567890123456789
NACOS_AUTH_TOKEN_EXPIRE_SECONDS=86400
Nginx配置
conf
upstream nacos-cluster { #这一部分配置在http{}内
server ip:8848;
server ip:8848;
server ip:8848;
}
server {
listen 9915;
server_name xxxxx;
location / {
proxy_pass http://nacos-cluster/;
proxy_set_header Host $host;
proxy_set_header X-Rea:l-IP $remote_addr;
}
access_log /var/log/nginx/nacos.log main;
查看集群状态
- nacos自带了metrics,可以对接上promethus
- 参考Nacos 监控手册
客户端原理(重点)
当客户端拿到配置后,需要动态刷新,从而保证数据和服务器端是一致的,这个过程是如何实现的呢?
Nacos采用长轮训机制来实现数据变更的同步,原理如下!
整体工作流程如下:
- 客户端发起长轮训请求
- 服务端收到请求以后,先比较服务端缓存中的数据是否相同,如果不通,则直接返回
- 如果相同,则通过schedule延迟29.5s之后再执行比较
- 为了保证当服务端在29.5s之内发生数据变化能够及时通知给客户端,服务端采用事件订阅的方式来监听服务端本地数据变化的事件,一旦收到事件,则触发DataChangeTask的通知,并且遍历allStubs队列中的ClientLongPolling,把结果写回到客户端,就完成了一次数据的推送
- 如果 DataChangeTask 任务完成了数据的 "推送" 之后,ClientLongPolling 中的调度任务又开始执行了怎么办呢?
很简单,只要在进行 "推送" 操作之前,先将原来等待执行的调度任务取消掉就可以了,这样就防止了推送操作写完响应数据之后,调度任务又去写响应数据,这时肯定会报错的。所以,在ClientLongPolling方法中,最开始的一个步骤就是删除订阅事件
长轮训任务启动入口
在NewConfigClient的方法中,当ConfigClient被实例化以后,有做一些事情
- 创建一个root的config.listenConfigExecutor,用于监听配置(主协程)
- 初始化一个GetHttpAgent
- 确定配置文件、日志目录、缓存目录
go
func NewConfigClient(nc nacos_client.INacosClient) (*ConfigClient, error) {
config := &ConfigClient{
cacheMap: cache.NewConcurrentMap(),
schedulerMap: cache.NewConcurrentMap(),
}
config.schedulerMap.Set("root", true)
# 主协程
go config.delayScheduler(time.NewTimer(1*time.Millisecond), 500*time.Millisecond, "root", config.listenConfigExecutor())
config.INacosClient = nc
clientConfig, err := nc.GetClientConfig()
if err != nil {
return config, err
}
# HttpAgent
serverConfig, err := nc.GetServerConfig()
if err != nil {
return config, err
}
httpAgent, err := nc.GetHttpAgent()
if err != nil {
return config, err
}
loggerConfig := logger.Config{
LogFileName: constant.LOG_FILE_NAME,
Level: clientConfig.LogLevel,
Sampling: clientConfig.LogSampling,
LogRollingConfig: clientConfig.LogRollingConfig,
LogDir: clientConfig.LogDir,
CustomLogger: clientConfig.CustomLogger,
LogStdout: clientConfig.AppendToStdout,
}
err = logger.InitLogger(loggerConfig)
if err != nil {
return config, err
}
logger.GetLogger().Infof("logDir:<%s> cacheDir:<%s>", clientConfig.LogDir, clientConfig.CacheDir)
config.configCacheDir = clientConfig.CacheDir + string(os.PathSeparator) + "config"
config.configProxy, err = NewConfigProxy(serverConfig, clientConfig, httpAgent)
if clientConfig.OpenKMS {
kmsClient, err := kms.NewClientWithAccessKey(clientConfig.RegionId, clientConfig.AccessKey, clientConfig.SecretKey)
if err != nil {
return config, err
}
config.kmsClient = kmsClient
}
return config, err
}
我们重点关注这行代码
go
go config.delayScheduler(time.NewTimer(1*time.Millisecond), 500*time.Millisecond, "root", config.listenConfigExecutor())
listenConfigExecutor
go
// Listen for the configuration executor
func (client *ConfigClient) listenConfigExecutor() func() error {
return func() error {
listenerSize := client.cacheMap.Count()
taskCount := int(math.Ceil(float64(listenerSize) / float64(perTaskConfigSize)))
currentTaskCount := int(atomic.LoadInt32(&client.currentTaskCount))
if taskCount > currentTaskCount {
for i := currentTaskCount; i < taskCount; i++ {
client.schedulerMap.Set(strconv.Itoa(i), true)
go client.delayScheduler(time.NewTimer(1*time.Millisecond), 10*time.Millisecond, strconv.Itoa(i), client.longPulling(i))
}
atomic.StoreInt32(&client.currentTaskCount, int32(taskCount))
} else if taskCount < currentTaskCount {
for i := taskCount; i < currentTaskCount; i++ {
if _, ok := client.schedulerMap.Get(strconv.Itoa(i)); ok {
client.schedulerMap.Set(strconv.Itoa(i), false)
}
}
atomic.StoreInt32(&client.currentTaskCount, int32(taskCount))
}
return nil
}
}
-
获取监听器数量和计算任务数量:
- 通过
client.cacheMap.Count()
获取当前注册的监听器数量listenerSize
。 - 根据监听器数量计算需要执行的任务数量
taskCount
,使用了数学库中的math.Ceil()
函数和类型转换。
- 通过
-
动态调整任务执行情况:
- 通过比较
taskCount
和currentTaskCount
的大小,判断是否需要启动新的任务或停止部分任务。 - 如果
taskCount
大于currentTaskCount
,则根据新增任务数量,通过循环启动新的任务,并将其加入到schedulerMap
中。 - 启动新任务的方式是调用
delayScheduler()
方法,在新的 Goroutine 中执行longPulling()
方法。 - 最后,使用原子操作更新
currentTaskCount
的值。
- 通过比较
🐶重点我们看看client.longPulling(i)。go的客户端是一层套一层,读起来还是很舒服的
go
go client.delayScheduler(time.NewTimer(1*time.Millisecond), 10*time.Millisecond, strconv.Itoa(i), client.longPulling(i))
longPulling
先通过本地配置的读取和检查来判断数据是否发生变化从而实现变化的通知
接着,当前的协程还需要去远程服务器上获得最新的数据,检查哪些数据发生了变化
- 通过匹配taskId获取远程服务器上数据变更的listeningConfigs
- 遍历这些变化的集合,然后调用ListenConfig从远程服务器获得对应的内容
- 更新本地的cache,设置为服务器端返回的内容
- 最后遍历cacheDatas,找到变化的数据进行调用callListener进行通知
go
// Long polling listening configuration
func (client *ConfigClient) longPulling(taskId int) func() error {
return func() error {
var listeningConfigs string
initializationList := make([]cacheData, 0)
for _, key := range client.cacheMap.Keys() {
if value, ok := client.cacheMap.Get(key); ok {
cData := value.(cacheData)
if cData.taskId == taskId {
if cData.isInitializing {
initializationList = append(initializationList, cData)
}
if len(cData.tenant) > 0 {
listeningConfigs += cData.dataId + constant.SPLIT_CONFIG_INNER + cData.group + constant.SPLIT_CONFIG_INNER +
cData.md5 + constant.SPLIT_CONFIG_INNER + cData.tenant + constant.SPLIT_CONFIG
} else {
listeningConfigs += cData.dataId + constant.SPLIT_CONFIG_INNER + cData.group + constant.SPLIT_CONFIG_INNER +
cData.md5 + constant.SPLIT_CONFIG
}
}
}
}
if len(listeningConfigs) > 0 {
clientConfig, err := client.GetClientConfig()
if err != nil {
logger.Errorf("[checkConfigInfo.GetClientConfig] err: %+v", err)
return err
}
// http get
params := make(map[string]string)
params[constant.KEY_LISTEN_CONFIGS] = listeningConfigs
var changed string
changedTmp, err := client.configProxy.ListenConfig(params, len(initializationList) > 0, clientConfig.NamespaceId, clientConfig.AccessKey, clientConfig.SecretKey)
if err == nil {
changed = changedTmp
} else {
if _, ok := err.(*nacos_error.NacosError); ok {
changed = changedTmp
} else {
logger.Errorf("[client.ListenConfig] listen config error: %+v", err)
}
return err
}
for _, v := range initializationList {
v.isInitializing = false
client.cacheMap.Set(util.GetConfigCacheKey(v.dataId, v.group, v.tenant), v)
}
if len(strings.ToLower(strings.Trim(changed, " "))) == 0 {
logger.Info("[client.ListenConfig] no change")
} else {
logger.Info("[client.ListenConfig] config changed:" + changed)
client.callListener(changed, clientConfig.NamespaceId)
}
}
return nil
}
}
callListener
那是如何调用,通知的呢?
需要遍历配置列表 changedConfigs
,对每个配置进行拆分并尝试从 cacheMap
中获取相应的配置信息。
- 如果获取成功,根据配置信息获取最新配置内容,并与缓存的 MD5 值进行比对。
- 如果 MD5 值发生变化,则执行监听器的回调函数,并更新缓存数据的 MD5 值和内容。
- 最后,在新的 Goroutine 中执行监听器的回调函数,并更新配置信息到缓存中。
这样就实现了配置监听通知变化
配置文件注册
前面我们了解到客户端是如何监听配置的。
那我们想要实现监听某个dataId,只需要注册进listenConfigExecutor即可
go
err = nacos.ConfClient.ListenConfig(vo.ConfigParam{
DataId: DataId,
Group: APIConfig.Nacos.NacosConfig.Group,
OnChange: func(namespace, group, dataId, data string)}
go
func (client *ConfigClient) ListenConfig(param vo.ConfigParam) (err error) {
if len(param.DataId) <= 0 {
err = errors.New("[client.ListenConfig] DataId can not be empty")
return err
}
if len(param.Group) <= 0 {
err = errors.New("[client.ListenConfig] Group can not be empty")
return err
}
clientConfig, err := client.GetClientConfig()
if err != nil {
err = errors.New("[checkConfigInfo.GetClientConfig] failed")
return err
}
key := util.GetConfigCacheKey(param.DataId, param.Group, clientConfig.NamespaceId)
var cData cacheData
if v, ok := client.cacheMap.Get(key); ok {
cData = v.(cacheData)
cData.isInitializing = true
} else {
var (
content string
md5Str string
)
if content, _ = cache.ReadConfigFromFile(key, client.configCacheDir); len(content) > 0 {
md5Str = util.Md5(content)
}
listener := &cacheDataListener{
listener: param.OnChange,
lastMd5: md5Str,
}
cData = cacheData{
isInitializing: true,
dataId: param.DataId,
group: param.Group,
tenant: clientConfig.NamespaceId,
content: content,
md5: md5Str,
cacheDataListener: listener,
taskId: client.cacheMap.Count() / perTaskConfigSize,
}
}
client.cacheMap.Set(key, cData)
return
}
整个代码其实就是为了凑cData,最后就client.cacheMap.Set(key, cData)
最后,再重新梳理一下,形成真正的闭环
ConfigProxy ListenConfig
来了,重点中的重点。客户端如何跟服务端请求数据的细节来了
nacos
采用的是客户端主动拉pull
模型,应用长轮询(Long Polling
)的方式来获取配置数据
客户端发起请求后,服务端不会立即返回请求结果,而是将请求挂起等待一段时间,如果此段时间内服务端数据变更,立即响应客户端请求,若是一直无变化则等到指定的超时时间后响应请求,客户端重新发起长链接。
go
func (cp *ConfigProxy) ListenConfig(params map[string]string, isInitializing bool, tenant, accessKey, secretKey string) (string, error) {
//fixed at 30000ms,avoid frequent request on the server
var listenInterval uint64 = 30000
headers := map[string]string{
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
"Long-Pulling-Timeout": strconv.FormatUint(listenInterval, 10),
}
if isInitializing {
headers["Long-Pulling-Timeout-No-Hangup"] = "true"
}
headers["accessKey"] = accessKey
headers["secretKey"] = secretKey
if len(tenant) > 0 {
params["tenant"] = tenant
}
logger.Infof("[client.ListenConfig] request params:%+v header:%+v \n", params, headers)
// In order to prevent the server from handling the delay of the client's long task,
// increase the client's read timeout to avoid this problem.
timeout := listenInterval + listenInterval/10
result, err := cp.nacosServer.ReqConfigApi(constant.CONFIG_LISTEN_PATH, params, headers, http.MethodPost, timeout)
return result, err
}
- 首先,设置了一个固定的监听间隔时间
listenInterval
,这个时间是30000毫秒,即30秒,用来控制客户端和服务端之间的请求间隔,避免频繁请求。 - 然后,定义了一个请求头
headers
,包括了Content-Type
和Long-Pulling-Timeout
,用来指定请求的内容类型和长轮询的超时时间。 - 如果
isInitializing
为true,即当前为初始化阶段,会设置Long-Pulling-Timeout-No-Hangup
为true,表示在长轮询超时时不断开连接。 - 接下来,根据传入的
accessKey
和secretKey
设置请求头中的accessKey
和secretKey
字段,用于进行身份验证。 - 如果传入的
tenant
参数不为空,将其添加到params
中。 - 然后,通过
cp.nacosServer
发送一个POST请求,请求路径为constant.CONFIG_LISTEN_PATH
,即配置监听路径。请求携带了params
作为参数,headers
作为请求头,以及超时时间为listenInterval
加上listenInterval
的十分之一的时长。
客户端缓存配置长轮训机制总结
整体实现的核心点就一下几个部分
-
对本地缓存的配置做任务拆分,每一个批次是3000条
-
针对每3000条创建一个线程去执行
-
先把每一个批次的缓存和本地磁盘文件中的数据进行比较,
- 如果和本地配置不一致,则表示该缓存发生了更新,直接通知客户端监听
- 如果本地缓存和磁盘数据一致,则需要发起远程请求检查配置变化
-
先以tenent/groupId/dataId拼接成字符串,发送到服务端进行检查,返回发生了变更的配置
-
客户端收到变更配置列表,再逐项遍历发送到服务端获取配置内容
服务端原理(重点)
服务端是如何处理客户端的请求的?那么同样,我们需要思考几个问题
- 服务端是如何实现长轮训机制的
- 客户端的超时时间为什么要设置30s
客户端发起的请求地址是:/v1/cs/configs/listener
,于是找到这个接口进行查看,代码如下。
java
//# ConfigController.java
@PostMapping("/listener")
@Secured(action = ActionTypes.READ, parser = ConfigResourceParser.class)
public void listener(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.setAttribute("org.apache.catalina.ASYNC_SUPPORTED", true);
String probeModify = request.getParameter("Listening-Configs");
if (StringUtils.isBlank(probeModify)) {
throw new IllegalArgumentException("invalid probeModify");
}
probeModify = URLDecoder.decode(probeModify, Constants.ENCODE);
Map<String, String> clientMd5Map;
try {
//解析客户端传递过来的可能发生变化的配置项目,转化为Map集合(key=dataId,value=md5)
clientMd5Map = MD5Util.getClientMd5Map(probeModify);
} catch (Throwable e) {
throw new IllegalArgumentException("invalid probeModify");
}
// 开始执行长轮训。
inner.doPollingConfig(request, response, clientMd5Map, probeModify.length());
}
doPollingConfig
这个方法主要是用来做长轮训和短轮询的判断
- 如果是长轮训,直接走addLongPollingClient方法
- 如果是短轮询,直接比较服务端的数据,如果存在md5不一致,直接把数据返回。
java
public String doPollingConfig(HttpServletRequest request, HttpServletResponse response,
Map<String, String> clientMd5Map, int probeRequestSize) throws IOException {
// 判断当前请求是否支持长轮训。()
if (LongPollingService.isSupportLongPolling(request)) {
longPollingService.addLongPollingClient(request, response, clientMd5Map, probeRequestSize);
return HttpServletResponse.SC_OK + "";
}
//如果是短轮询,走下面的请求,下面的请求就是把客户端传过来的数据和服务端的数据逐项进行比较,保存到changeGroups中。
// Compatible with short polling logic.
List<String> changedGroups = MD5Util.compareMd5(request, response, clientMd5Map);
// Compatible with short polling result.
String oldResult = MD5Util.compareMd5OldResult(changedGroups);
String newResult = MD5Util.compareMd5ResultString(changedGroups);
String version = request.getHeader(Constants.CLIENT_VERSION_HEADER);
if (version == null) {
version = "2.0.0";
}
int versionNum = Protocol.getVersionNumber(version);
// Before 2.0.4 version, return value is put into header.
if (versionNum < START_LONG_POLLING_VERSION_NUM) {
response.addHeader(Constants.PROBE_MODIFY_RESPONSE, oldResult);
response.addHeader(Constants.PROBE_MODIFY_RESPONSE_NEW, newResult);
} else {
request.setAttribute("content", newResult);
}
Loggers.AUTH.info("new content:" + newResult);
// Disable cache.
response.setHeader("Pragma", "no-cache");
response.setDateHeader("Expires", 0);
response.setHeader("Cache-Control", "no-cache,no-store");
response.setStatus(HttpServletResponse.SC_OK);
return HttpServletResponse.SC_OK + "";
}
addLongPollingClient
把客户端的请求,保存到长轮训的执行引擎中。
java
public void addLongPollingClient(HttpServletRequest req, HttpServletResponse rsp, Map<String, String> clientMd5Map,
int probeRequestSize) {
//获取客户端长轮训的超时时间
String str = req.getHeader(LongPollingService.LONG_POLLING_HEADER);
//不允许断开的标记
String noHangUpFlag = req.getHeader(LongPollingService.LONG_POLLING_NO_HANG_UP_HEADER);
//应用名称
String appName = req.getHeader(RequestUtil.CLIENT_APPNAME_HEADER);
//
String tag = req.getHeader("Vipserver-Tag");
//延期时间,默认为500ms
int delayTime = SwitchService.getSwitchInteger(SwitchService.FIXED_DELAY_TIME, 500);
// Add delay time for LoadBalance, and one response is returned 500 ms in advance to avoid client timeout.
// 提前500ms返回一个响应,避免客户端出现超时
long timeout = Math.max(10000, Long.parseLong(str) - delayTime);
if (isFixedPolling()) {
timeout = Math.max(10000, getFixedPollingInterval());
// Do nothing but set fix polling timeout.
} else {
long start = System.currentTimeMillis();
//通过md5判断客户端请求过来的key是否有和服务器端有不一致的,如果有,则保存到changedGroups中。
List<String> changedGroups = MD5Util.compareMd5(req, rsp, clientMd5Map);
if (changedGroups.size() > 0) { //如果发现有变更,则直接把请求返回给客户端
generateResponse(req, rsp, changedGroups);
LogUtil.CLIENT_LOG.info("{}|{}|{}|{}|{}|{}|{}", System.currentTimeMillis() - start, "instant",
RequestUtil.getRemoteIp(req), "polling", clientMd5Map.size(), probeRequestSize,
changedGroups.size());
return;
} else if (noHangUpFlag != null && noHangUpFlag.equalsIgnoreCase(TRUE_STR)) { //如果noHangUpFlag为true,说明不需要挂起客户端,所以直接返回。
LogUtil.CLIENT_LOG.info("{}|{}|{}|{}|{}|{}|{}", System.currentTimeMillis() - start, "nohangup",
RequestUtil.getRemoteIp(req), "polling", clientMd5Map.size(), probeRequestSize,
changedGroups.size());
return;
}
}
//获取请求端的ip
String ip = RequestUtil.getRemoteIp(req);
// Must be called by http thread, or send response.
//把当前请求转化为一个异步请求(意味着此时tomcat线程被释放,也就是客户端的请求,需要通过asyncContext来手动触发返回,否则一直挂起)
final AsyncContext asyncContext = req.startAsync();
// AsyncContext.setTimeout() is incorrect, Control by oneself
asyncContext.setTimeout(0L); //设置异步请求超时时间,
//执行长轮训请求
ConfigExecutor.executeLongPolling(
new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout, appName, tag));
}
ClientLongPolling
- 智能化延迟执行:clientLongPolling 任务巧妙地采用延迟执行机制,在约 29.5 秒后执行。这不仅有效减少了不必要的请求,还能在一定程度上节省网络资源。这种智能化的延迟执行策略有助于提高系统的性能表现。
- 精准监控机制:通过定期执行任务并比对 MD5 值,clientLongPolling 任务精准地监控配置信息的变化。这种高效的监控机制保证了客户端能够在数据发生变化时及时获取到最新的配置信息。
- 即时通知订阅机制:客户端通过订阅机制与服务端建立了即时通信的桥梁,一旦配置信息发生变化,服务端能够立即通知所有订阅了相关配置的客户端。这种高效的订阅通知机制保证了系统能够快速响应变化,实现了配置信息的实时更新和同步。
这些优化措施不仅提升了系统的性能和效率,同时也增强了系统的可靠性和实时性,为用户提供了更优质的服务体验。
java
class ClientLongPolling implements Runnable {
@Override
public void run() {
//构建一个异步任务,延后29.5s执行
asyncTimeoutFuture = ConfigExecutor.scheduleLongPolling(new Runnable() {
@Override
public void run() { //如果达到29.5s,说明这个期间没有做任何配置修改,则自动触发执行
try {
getRetainIps().put(ClientLongPolling.this.ip, System.currentTimeMillis());
// Delete subsciber's relations.
allSubs.remove(ClientLongPolling.this); //移除订阅关系
if (isFixedPolling()) { //如果是固定间隔的长轮训
LogUtil.CLIENT_LOG
.info("{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - createTime), "fix",
RequestUtil.getRemoteIp((HttpServletRequest) asyncContext.getRequest()),
"polling", clientMd5Map.size(), probeRequestSize);
//比较变更的key
List<String> changedGroups = MD5Util
.compareMd5((HttpServletRequest) asyncContext.getRequest(),
(HttpServletResponse) asyncContext.getResponse(), clientMd5Map);
if (changedGroups.size() > 0) {//如果大于0,表示有变更,直接响应
sendResponse(changedGroups);
} else {
sendResponse(null); //否则返回null
}
} else {
LogUtil.CLIENT_LOG
.info("{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - createTime), "timeout",
RequestUtil.getRemoteIp((HttpServletRequest) asyncContext.getRequest()),
"polling", clientMd5Map.size(), probeRequestSize);
sendResponse(null);
}
} catch (Throwable t) {
LogUtil.DEFAULT_LOG.error("long polling error:" + t.getMessage(), t.getCause());
}
}
}, timeoutTime, TimeUnit.MILLISECONDS);
allSubs.add(this); //把当前线程添加到订阅事件队列中
}
}
allSubs
java
/**
* 长轮询订阅关系
*/
final Queue<ClientLongPolling> allSubs;
allSubs.add(this);
LongPollingService
在 LongPollingService
的构造方法中,通过订阅 LocalDataChangeEvent
事件来监听服务端数据的变更是一个高效的做法。当事件触发时,执行 DataChangeTask
线程以处理数据变更。这种设计保证了系统能够实时响应数据的变化,并在需要时进行相应的处理。
DataChangeTask
java
class DataChangeTask implements Runnable {
@Override
public void run() {
try {
ConfigCacheService.getContentBetaMd5(groupKey); //
//遍历所有订阅事件表
for (Iterator<ClientLongPolling> iter = allSubs.iterator(); iter.hasNext(); ) {
ClientLongPolling clientSub = iter.next(); //得到ClientLongPolling
//判断当前的ClientLongPolling中,请求的key是否包含当前修改的groupKey
if (clientSub.clientMd5Map.containsKey(groupKey)) {
// If published tag is not in the beta list, then it skipped.
if (isBeta && !CollectionUtils.contains(betaIps, clientSub.ip)) { //如果是beta方式且betaIps不包含当前客户端ip,直接返回
continue;
}
// If published tag is not in the tag list, then it skipped.
if (StringUtils.isNotBlank(tag) && !tag.equals(clientSub.tag)) {//如果配置了tag标签且不包含当前客户端的tag,直接返回
continue;
}
//
getRetainIps().put(clientSub.ip, System.currentTimeMillis());
iter.remove(); // Delete subscribers' relationships. 移除当前客户端的订阅关系
LogUtil.CLIENT_LOG
.info("{}|{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - changeTime), "in-advance",
RequestUtil
.getRemoteIp((HttpServletRequest) clientSub.asyncContext.getRequest()),
"polling", clientSub.clientMd5Map.size(), clientSub.probeRequestSize, groupKey);
clientSub.sendResponse(Arrays.asList(groupKey)); //响应客户端请求。
}
}
} catch (Throwable t) {
LogUtil.DEFAULT_LOG.error("data change error: {}", ExceptionUtil.getStackTrace(t));
}
}
}
服务端总结
Nacos ⼀致性协议
单机下其实问题不大,简单的内嵌关系型数据库即可;
但是集群模式下,就需要考虑如何保障各个节点之间的数据⼀致性以及数据同步,而要解决这个问题,就不得不引入共识算法,通过算法来保障各个节点之间的数据的⼀致性。
为什么 Nacos 选择了 Raft 以及 Distro
为什么 Nacos 会在单个集群中同时运行 CP 协议以及 AP 协议呢?这其实要从 Nacos 的场景出发的:Nacos 是⼀个集服务注册发现以及配置管理于⼀体的组件,因此对于集群下,各个节点之间的数据⼀致性保障问题,需要拆分成两个方面
从服务注册发现来看
服务之间感知对方服务的当前可正常提供服务的实例信息,必须从服务发现注册中心进行获取,因此对于服务注册发现中心组件的可用性,提出了很高的要求,需要在任何场景下,尽最大可能保证服务注册发现能力可以对外提供服务;
同时 Nacos 的服务注册发现设计,采取了心跳可自动完成服务数据补偿的机制。如果数据丢失的话,是可以通过该机制快速弥补数据丢失。
因此,为了满足服务发现注册中心的可用性,强⼀致性的共识算法这里就不太合适了,因为强⼀致性共识算法能否对外提供服务是有要求的,如果当前集群可用的节点数没有过半的话,整个算法直接"罢工",而最终⼀致共识算法的话,更多保障服务的可用性,并且能够保证在⼀定的时间内各个节点之间的数据能够达成⼀致。
上述的都是针对于 Nacos 服务发现注册中的非持久化服务而言(即需要客户端上报心跳进行服务实例续约)。
而对于 Nacos 服务发现注册中的持久化服务,因为所有的数据都是直接使用调用 Nacos服务端直接创建,因此需要由 Nacos 保障数据在各个节点之间的强⼀致性,故而针对此类型的服务数据,选择了强⼀致性共识算法来保障数据的⼀致性
从配置管理来看
配置数据,是直接在 Nacos 服务端进行创建并进行管理的,必须保证大部分的节点都保存了此配置数据才能认为配置被成功保存了,否则就会丢失配置的变更,如果出现这种情况,问题是很严重的,如果是发布重要配置变更出现了丢失变更动作的情况,那多半就要引起严重的现网故障了,因此对于配置数据的管理,是必须要求集群中大部分的节点是强⼀致的,而这里的话只能使用强⼀致性共识算法
Raft (CP模式)
对于强⼀致性共识算法,当前工业生产中,最多使用的就是 Raft 协议,Raft 协议更容易让人理解,并且有很多成熟的工业算法实现,比如
- 蚂蚁金服的 JRaft
- Zookeeper 的 ZAB
- Consul 的 Raft
- 百度的 braft
- Apache Ratis
因为 Nacos 是 Java 技术栈,因此只能在 JRaft、ZAB、ApacheRatis 中选择,但是 ZAB 因为和 Zookeeper 强绑定,再加上希望可以和 Raft 算法库的支持团队沟通交流,因此选择了 JRaft,选择 JRaft 也是因为 JRaft 支持多 RaftGroup,为 Nacos 后面的多数据分片带来了可能。
Distro (AP模式)
而 Distro 协议是阿里巴巴自研的⼀个最终⼀致性协议,而最终⼀致性协议有很多,比如 Gossip、Eureka 内的数据同步算法。而 Distro 算法是集 Gossip 以及 Eureka 协议的优点并加以优化而出来的,对于原生的 Gossip,由于随机选取发送消息的节点,也就不可避免的存在消息重复发送给同⼀节点的情况,增加了网络的传输的压力,也给消息节点带来额外的处理负载,而 Distro 算法引入
了权威 Server 的概念,每个节点负责⼀部分数据以及将自己的数据同步给其他节点,有效的降低了消息冗余的问题。