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的数据存储分为两部分
1. Mysql数据库存储,所有Nacos节点共享同一份数据,数据的副本机制由Mysql本身的主从方案来解决,从而保证数据的可靠性。
2. 每个节点的本地磁盘,会保存一份全量数据,具体路径:`/data/program/nacos-1/data/config-data/${GROUP}`.
在Nacos的设计中,Mysql是一个中心数据仓库,且认为在Mysql中的数据是绝对正确的。 除此之外,Nacos在启动时会把Mysql中的数据写一份到本地磁盘。
> 这么设计的好处是可以提高性能,当客户端需要请求某个配置项时,服务端会想Ian从磁盘中读取对应文件返回,而磁盘的读取效率要比数据库效率高。
当配置发生变更时:
1. Nacos会把变更的配置保存到数据库,然后再写入本地文件。
2. 接着发送一个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 监控手册](https://link.juejin.cn?target=https%3A%2F%2Fnacos.io%2Fzh-cn%2Fdocs%2Fmonitor-guide.html "https://nacos.io/zh-cn/docs/monitor-guide.html")
 
*** ** * ** ***
# 客户端原理(重点)
当客户端拿到配置后,需要动态刷新,从而保证数据和服务器端是一致的,这个过程是如何实现的呢?
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
}
}
```
1. 获取监听器数量和计算任务数量:
* 通过 `client.cacheMap.Count()` 获取当前注册的监听器数量 `listenerSize`。
* 根据监听器数量计算需要执行的任务数量 `taskCount`,使用了数学库中的 `math.Ceil()` 函数和类型转换。
2. 动态调整任务执行情况:
* 通过比较 `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` 中获取相应的配置信息。
1. 如果获取成功,根据配置信息获取最新配置内容,并与缓存的 MD5 值进行比对。
2. 如果 MD5 值发生变化,则执行监听器的回调函数,并更新缓存数据的 MD5 值和内容。
3. 最后,在新的 Goroutine 中执行监听器的回调函数,并更新配置信息到缓存中。
这样就实现了配置监听通知变化
```mermaid
graph LR
NewConfigClient --> listenConfigExecutor
listenConfigExecutor --> longPulling
longPulling --> changedTmp
changedTmp --> callListener
```
### 配置文件注册
前面我们了解到客户端是如何监听配置的。
那我们想要实现监听某个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)

最后,再重新梳理一下,形成真正的闭环
```mermaid
graph LR
NewConfigClient --> listenConfigExecutor
ListenConfig --> listenConfigExecutor
listenConfigExecutor --> longPulling
longPulling --> changedTmp
changedTmp --> callListener
callListener --> ListenConfig
```
### 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`的十分之一的时长。

### 客户端缓存配置长轮训机制总结
整体实现的核心点就一下**几个部分**
1. 对本地缓存的配置做任务拆分,每一个批次是3000条
2. 针对每3000条创建一个线程去执行
3. 先把每一个批次的缓存和本地磁盘文件中的数据进行比较,
1. 如果和本地配置不一致,则表示该缓存发生了更新,直接通知客户端监听
2. 如果本地缓存和磁盘数据一致,则需要发起远程请求检查配置变化
4. 先以tenent/groupId/dataId拼接成字符串,发送到服务端进行检查,返回发生了变更的配置
5. 客户端收到变更配置列表,再逐项遍历发送到服务端获取配置内容

*** ** * ** ***
# 服务端原理(重点)
服务端是如何处理客户端的请求的?那么同样,我们需要思考几个问题
* 服务端是如何实现长轮训机制的
* 客户端的超时时间为什么要设置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