client-go初级篇,从操作kubernetes到编写单元测试

欢迎访问我的GitHub

这里分类和汇总了欣宸的全部原创(含配套源码):github.com/zq2599/blog...

本篇概览

  • 尽管长篇系列《client-go实战》的内容足够丰富,然而内容太多每个知识点也有一定深度,对于打算快速学习并开始kubernetes开发的新手并不友好,因此本篇的目标读者就是client-go初学者,重点解决两个基础问题:
  1. 如何编码操作kubernetes?
  2. 对应的单元测试代码怎么写,运行单元测试时可是没有kubernetes环境的,这时咱们写的那些操作kubernetes的代码能运行吗?
  • 注意一:本篇写的代码是Go语言
  • 注意二:文末有源码下载地址,对应本篇的完整工程源码

环境信息

  • 以下是本篇实战涉及的软件的版本信息,作为您的参考
  1. go:1.19.3
  2. kubernetes:1.22.8
  3. client-go:v0.22.8
  4. 开发机:Ubuntu 20.04.4 LTS
  5. 编码环境:Windows 11 家庭中文版 + vs code 1.79.2
  • 这里顺便提一下,编码环境不重要,我这里使用vs code的Remote Explorer插件远程连接到开发机上进行操作,也就是说写代码用的是windows编码环境,实际编译和运行都在开发机Ubuntu上面,如下图
  • 本篇的主题是编码操作kubernetes,因此请确保kubernetes环境已经就绪

如何编码操作kubernetes?

  • 想要编码操作kubernetes,需要使用client-go库,因此本篇主要演示的就是如何使用该库
  • 首先要确定client-go的版本,这和您自己的kubernetes环境有关,在确定了kubernetes版本后如何确定client-go的版本呢?来看client-go官方说明,如下图
  • 简单解释一下如何确定版本
  1. client-go的版本一共有两类:旧版的kubernetes-1.x.y和新版v0.x.y
  2. 如果kubernetes版本大于或等于1.17.0,client-go版本请选择新版,举例:如果kubernetes版本是1.20.4,client-go版本就是v0.20.4
  3. 如果kubernetes版本小于1.17.0,client-go版本请选择旧版,举例:如果kubernetes版本是1.20.4,client-go版本就是kubernetes-1.16.3
  4. 综上所述,本文使用:kubernetes:1.22.8和client-go:v0.22.8的组合

方案设计

  • 正式编码前先说清楚要开发的内容,整体架构如下:

  • 下面的具体的步骤:

  1. 开发一个web服务,名为client-go-unit-tutorials,基于gin框架
  2. 提供一个接口query_pods_by_label_app,作用是根据namespace和label的值,查询出符合条件的所有pod的名称
  3. 上述接口的具体实现用到了client-go库,使用库中的api去kubernetes的api-server查找pod,将结果的name作为接口的返回值,返回给请求方
  4. client-go库要想成功访问kubernetes,必须要有kubernetes环境的.kube/config文件,这里为了省事儿,直接将web服务部署到kubernetes环境的机器上,这样就能直接访问.kube/config文件了
  5. 编写单元测试代码,在没有kubernetes环境的情况下,也能成功执行那段操作kubernetes的代码
  • 再次提醒:client-go-unit-tutorials可以在一个独立的机器上运行,也能直接运行在kubernetes机器上,还能做成镜像运行在kubernetes环境
  • 接下来开始编码吧

编码:准备工程

编码:操作kubernetes

  • 新建文件夹kubernetes_service,在里面新增文件kube.go,这是集中了kubernetes操作的代码,内容如下,这里面有几处要注意的地方,稍后会提到
go 复制代码
package kubernetesservice

import (
	"context"
	"flag"
	"log"
	"path/filepath"
	"sync"

	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/tools/clientcmd"
	"k8s.io/client-go/util/homedir"

	v1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/labels"
	"k8s.io/apimachinery/pkg/selection"
)

var CLIENT_SET kubernetes.Interface
var ONCE sync.Once

// DoInit Indexer相关的初始化操作,这里确保只执行一次
func DoInit() {
	ONCE.Do(initInKubernetesEnv)
}

// GetClient 调用此方法返回clientSet对象
func GetClient() kubernetes.Interface {
	return CLIENT_SET
}

// SetClient 可以通过initInKubernetesEnv在kubernetes初始化,如果有准备好的clientSet,也可以调用SetClient直接设置,而无需初始化
func SetClient(clientSet kubernetes.Interface) {
	CLIENT_SET = clientSet
}

// initInKubernetesEnv 这里是真正的初始化逻辑
func initInKubernetesEnv() {
	log.Println("开始初始化Indexer")

	var kubeconfig *string

	// 试图取到当前账号的家目录
	if home := homedir.HomeDir(); home != "" {
		// 如果能取到,就把家目录下的.kube/config作为默认配置文件
		kubeconfig = flag.String("kubeconfig", filepath.Join(home, ".kube", "config"), "(optional) absolute path to the kubeconfig file")
	} else {
		// 如果取不到,就没有默认配置文件,必须通过kubeconfig参数来指定
		kubeconfig = flag.String("kubeconfig", "", "absolute path to the kubeconfig file")
	}

	// 加载配置文件
	config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)
	if err != nil {
		panic(err.Error())
	}

	// 用clientset类来执行后续的查询操作
	CLIENT_SET, err = kubernetes.NewForConfig(config)
	if err != nil {
		panic(err.Error())
	}

	log.Println("kubernetes服务初始化成功")
}

// QueryPodNameByLabelApp 根据指定的namespace和label值搜索
func QueryPodNameByLabelApp(context context.Context, namespace, app string) ([]string, error) {
	log.Printf("QueryPodNameByLabelApp, namespace [%s], app [%s]", namespace, app)

	equalRequirement, err := labels.NewRequirement("app", selection.Equals, []string{app})

	if err != nil {
		return nil, err
	}

	selector := labels.NewSelector().Add(*equalRequirement)

	// 查询pod列表
	pods, err := CLIENT_SET.
		CoreV1().
		Pods(namespace).
		List(context, metav1.ListOptions{
			// 传入的selector在这里用到
			LabelSelector: selector.String(),
		})

	if err != nil {
		return nil, err
	}

	names := make([]string, 0)

	for _, v := range pods.Items {
		names = append(names, v.GetName())
	}

	return names, nil
}

// CreateNamespace 单元测试的辅助工具,用于创建namespace
func CreateNamespace(context context.Context, client kubernetes.Interface, name string) error {
	namespaceObj := &v1.Namespace{
		ObjectMeta: metav1.ObjectMeta{
			Name: name,
		},
	}

	_, err := client.CoreV1().Namespaces().Create(context, namespaceObj, metav1.CreateOptions{})
	return err
}

// DeleteeNamespace 单元测试的辅助工具,用于创建namespace
func DeleteNamespace(context context.Context, client kubernetes.Interface, name string) error {
	err := client.CoreV1().Namespaces().Delete(context, name, metav1.DeleteOptions{})
	return err
}

/*
// CreateDeployment 单元测试的辅助工具,用于创建namespace
func CreateDeployment(context context.Context, client kubernetes.Interface, namespace, name, image, app string, replicas int32) error {
	_, err := client.AppsV1().Deployments(namespace).Create(context, &apps.Deployment{
		TypeMeta: metav1.TypeMeta{
			Kind:       "Deployment",
			APIVersion: "apps/v1",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      name,
			Namespace: namespace,
			Labels: map[string]string{
				"app": app,
			},
		},
		Spec: apps.DeploymentSpec{
			Replicas: &replicas,
			Template: v1.PodTemplateSpec{
				Spec: v1.PodSpec{
					Containers: []v1.Container{
						{
							Image: image,
						},
					},
				},
			},
		},
	}, metav1.CreateOptions{})

	return err

}
*/
  • 上述代码要有以下几处需要注意
  1. 操作kubernetes需要使用Clientset对象,该对象的创建过程集中在initInKubernetesEnv方法中,先加载配置文件,再根据配置文件创建Clientset
  2. CreateNamespace方法的作用是创建namespace,这里面有常规client-go库的使用方法,即Clientset的api的一些操作,以及一些资源对象的初始化
  3. QueryPodNameByLabelApp方法实现了核心业务功能,稍微有点复杂,在查找pod对象的时候,使用label做了一次过滤
  • 接下来是响应web请求的服务类basic_curd.go,代码如下,可见非常简单,就是取请求参数,再调用上面写到的api去kubernetes查询,再返回即可
go 复制代码
package handler

import (
	"log"
	"net/http"

	"github.com/gin-gonic/gin"

	kubernetesservice "client-go-unit-tutorials/kubernetes_service"
)

const (
	PARAM_NAMESPACE = "namespace"
	PARAM_APP       = "label_app"
)

func QueryPodsByLabelApp(context *gin.Context) {
	rlt := make(map[string]interface{})
	namespace := context.DefaultQuery(PARAM_NAMESPACE, "")
	app := context.DefaultQuery(PARAM_APP, "")

	log.Printf("query param, namespace [%s], app [%s]", namespace, app)
	names, err := kubernetesservice.QueryPodNameByLabelApp(context, namespace, app)

	if err != nil {
		rlt["message"] = err.Error()
		context.JSON(http.StatusInternalServerError, rlt)
		return
	}

	rlt["message"] = "success"
	rlt["names"] = names
	context.JSON(http.StatusOK, rlt)
}
  • 为了将gin初始化逻辑封装起来好给外部调用,这里创建了/initor/customize_initor.go,内容也很简单,就是gin的路由初始化
go 复制代码
package initor

import (
	"github.com/gin-gonic/gin"

	"client-go-unit-tutorials/handler"
)

const (
	PATH_QUERY_PODS_BY_LABEL_APP = "/query_pods_by_label_app"
)

func InitRouter() *gin.Engine {
	r := gin.Default()

	// 绑定path的handler
	r.GET(PATH_QUERY_PODS_BY_LABEL_APP, handler.QueryPodsByLabelApp)

	return r
}
  • 最后是main.go,这里面很简单,主动调用kubernetes和gin的初始化方法
go 复制代码
package main

import (
	"client-go-unit-tutorials/initor"
	kubernetesservice "client-go-unit-tutorials/kubernetes_service"
)

func main() {
	// 初始化kubernetes相关配置
	kubernetesservice.DoInit()

	router := initor.InitRouter()
	_ = router.Run(":18080")
}
  • 以上就是完整的代码了,接下来咱们把代码运行起来看看效果

运行代码前的准备工作

  • 首先要在kubernetes环境把deployment部署好,如此调用查询接口才有数据返回
  • 先创建namespace
shell 复制代码
kubectl create namespace client-go-tutorials
  • 创建名为nginx-deployment-service.yaml的文件,内容如下
yaml 复制代码
---
apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: client-go-tutorials
  name: nginx-deployment
  labels:
    app: nginx-app
    type: front-end
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx-app
      type: front-end
  template:
    metadata:
      labels:
        app: nginx-app
        type: front-end
        # 这是第一个业务自定义label,指定了mysql的语言类型是c语言
        language: c
        # 这是第二个业务自定义label,指定了这个pod属于哪一类服务,nginx属于web类
        business-service-type: web
    spec:
      containers:
        - name: nginx-container
          image: nginx:latest
          resources:
            limits:
              cpu: "0.5"
              memory: 128Mi
            requests:
              cpu: "0.1"
              memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
  namespace: client-go-tutorials
  name: nginx-service
spec:
  type: NodePort
  selector:
    app: nginx-app
    type: front-end
  ports:
    - port: 80
      targetPort: 80
      nodePort: 30011
  • 执行以下脚本完成部署
shell 复制代码
kubectl apply -f nginx-deployment-service.yaml
  • 稍后会创建三个nginx的pod,接下来咱们就要用代码来查询这些pod了
shell 复制代码
kubectl get pods -n client-go-tutorials
NAME                                READY   STATUS    RESTARTS   AGE
nginx-deployment-78f6b696d9-j98xj   1/1     Running   0          19h
nginx-deployment-78f6b696d9-wp4qf   1/1     Running   0          7d17h
nginx-deployment-78f6b696d9-wpnt7   1/1     Running   0          20h

运行代码

  • 正常情况下,应该是执行go build编译项目,得到名为client-go-unit-tutorials的可执行文件,部署在可以访问kubernetes的机器上运行
  • 我这边开发机上就部署着kubernetes,因此,只要在vscode上运行项目就行了,运行应用的配置文件launch.json,如下
json 复制代码
{
    "version": "0.2.0",
    "configurations": [
        
        {
            "name": "Launch Package",
            "type": "go",
            "request": "launch",
            "mode": "auto",
            "program": "${workspaceFolder}"
        }
    ]
}
  • 调用请求的方法很多,postman、curl命令都可以,我这里用的是vscode的REST Client插件,可以把请求以脚本的方式保存下来,脚本如下
shell 复制代码
### 变量
@namespace=client-go-tutorials
@label_app=nginx-app

### 测试用例,指定namespace和label查询所有的pod名称
GET http://192.168.50.76:18080/query_pods_by_label_app?namespace={{namespace}}&label_app={{label_app}}
  • 点击下图红色箭头所指的Send Request就会发送请求
  • 收到响应如下,可见所有符合要求的pod的name都在响应body中了
shell 复制代码
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sun, 02 Jul 2023 04:58:03 GMT
Content-Length: 139
Connection: close

{
  "message": "success",
  "names": [
    "nginx-deployment-78f6b696d9-j98xj",
    "nginx-deployment-78f6b696d9-wp4qf",
    "nginx-deployment-78f6b696d9-wpnt7"
  ]
}
  • 至此,整篇内容已经完成了二分之一,接下里要看的就是如何编写单元测试代码了,要在一个没有kubernetes的环境下成功运行操作kubernetes的代码

关键知识点:使用client-go库的代码如何写单元测试

  • 如果您只想了解client-go有关的单元测试的关键知识点,对其他内容不感兴趣,下面黄色箭头所指这行代码足够了,在单元测试中使用fake.NewSimpleClientset()创建的clientset,只要运行单元测试时应用代码用到的是这个clientset,就可以和实际kubernetes环境使用clientset一样了,创建的资源也能被查出来
  • 打开上图中的NewSimpleClientset方法,看看它创建的clientset是何方神圣,如下图,这个fake包下面的Clientset,已经把kubernetes.Interface接口完整实现了,在单元测试中可以用来取代正式环境中调用kubernetes.NewForConfig创建的clentset对象
  • 以上解答了单元测试时如何脱离kubernetes环境使用client-go库的问题,这只是一个技术点而已,接下来咱们把完整的单元测试代码写出来

编码:单元测试

  • 首先是辅助工具,这里面有多个方法,都是辅助单元测试的,例如SingleTest方法可以用来发送请求并将响应返回,Check方法可以检查返回内容等
go 复制代码
package unittesthelper

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/suite"
	v1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/client-go/kubernetes"
)

const (
	TEST_NAMESPACE       = "client-go-tutorials"
	TEST_POD_NAME_PREFIX = "nginx-pod-"
	TEST_IMAGE           = "nginx:latest"
	TEST_LABEL_APP       = "nginx-app"
	TEST_POD_NUM         = 3
)

// 数据结构,用于保存web响应的body
type ResponseNames struct {
	Message string   `json:"message"`
	Names   []string `json:"names"`
}

// SingleTest 辅助方法,发请求,返回响应
func SingleTest(router *gin.Engine, url string) (int, string, error) {
	log.Printf("start SingleTest, request url : %s", url)
	w := httptest.NewRecorder()
	req, _ := http.NewRequest(http.MethodGet, url, nil)
	router.ServeHTTP(w, req)
	return w.Code, w.Body.String(), nil
}

// 9. 辅助方法,解析web响应,检查结果是否符合预期
func Check(suite *suite.Suite, body string, expectNum int) {
	suite.NotNil(body)
	response := &ResponseNames{}

	err := json.Unmarshal([]byte(body), response)

	if err != nil {
		log.Fatalf("unmarshal response error, %s", err.Error())
	}

	suite.EqualValues(expectNum, len(response.Names))
}

// CreatePodObj 辅助方法,用于创建pod对象
func CreatePodObj(namespace, name, app, image string) *v1.Pod {
	return &v1.Pod{
		TypeMeta: metav1.TypeMeta{
			Kind:       "Deployment",
			APIVersion: "apps/v1",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      name,
			Namespace: namespace,
			Labels: map[string]string{
				"app": app,
			},
		},
		Spec: v1.PodSpec{
			Containers: []v1.Container{
				{
					Image: image,
				},
			},
		},
	}
}

// CreateDeployment 单元测试的辅助工具,用于创建namespace
func CreatePods(context context.Context, client kubernetes.Interface, namespace, name, image, app string) error {
	_, err := client.CoreV1().Pods(namespace).Create(context, CreatePodObj(namespace, name, app, image), metav1.CreateOptions{})
	return err
}

// CreatePod 辅助方法,用于创建多个pod
func CreatePod(context context.Context, client kubernetes.Interface, num int) {
	for i := 0; i < num; i++ {
		if err := CreatePods(context,
			client,
			TEST_NAMESPACE,
			fmt.Sprintf("%s%d", TEST_POD_NAME_PREFIX, i),
			TEST_IMAGE,
			TEST_LABEL_APP); err != nil {
			log.Fatalf("create pod [%d] error, %s", i, err.Error())
		}
	}
}
  • 接下来就是完整的单元测试代码,这里面是一个常规的单元测试集的开发,注释中用数字标明了每一步的执行顺序,按部就班完成就好,要注意的是SetupTest方法,里面用mock出来的clientset创建了三个pod,这些pod在查询的时候是可以被查出来的,有了这个mock版的clientset的帮助,就算没有kubernetes环境,咱们的代码照样能正常运行
go 复制代码
package handler_test

import (
	"client-go-unit-tutorials/handler"
	"client-go-unit-tutorials/initor"
	kubernetesservice "client-go-unit-tutorials/kubernetes_service"
	"client-go-unit-tutorials/unittesthelper"
	"context"
	"fmt"
	"log"
	"net/http"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/suite"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/kubernetes/fake"
)

// 1. 定义suite数据结构
type MySuite struct {
	suite.Suite
	ctx       context.Context
	cancel    context.CancelFunc
	clientSet kubernetes.Interface
	router    *gin.Engine
}

// 2. 单元测试的初始化操作
func (mySuite *MySuite) SetupTest() {
	client := fake.NewSimpleClientset()
	kubernetesservice.SetClient(client)

	mySuite.ctx, mySuite.cancel = context.WithCancel(context.Background())
	mySuite.clientSet = client
	mySuite.router = initor.InitRouter()

	// 初始化数据,创建namespace
	if err := kubernetesservice.CreateNamespace(mySuite.ctx, client, unittesthelper.TEST_NAMESPACE); err != nil {
		log.Fatalf("create namespace error, %s", err.Error())
	}

	// 初始化数据,创建pod
	unittesthelper.CreatePod(mySuite.ctx, client, 3)
}

// 3. 定义测试完成后的收尾工作,例如清理一些资源
func (mySuite *MySuite) TearDownTest() {

	// 删除namespace
	if err := kubernetesservice.DeleteNamespace(mySuite.ctx, kubernetesservice.GetClient(), unittesthelper.TEST_NAMESPACE); err != nil {
		log.Fatalf("delete namespace error, %s", err.Error())
	}

	mySuite.cancel()
}

// 4. 启动测试集
func TestBasicCrud(t *testing.T) {
	suite.Run(t, new(MySuite))
}

// 5. 定义测试集
func (mySuite *MySuite) TestBasicCrud() {
	// 5.1 若有需要,执行monkey.Patch
	// 5.2 若执行了monkey.Patch,需要执行defer monkey.UnpatchAll()

	// 5.3 执行单个测试
	// 参考 client-go/examples/fake-client/main_test.go/main_test.go
	mySuite.Run("常规查询", func() {
		url := fmt.Sprintf("%s?%s=%s&%s=%s",
			initor.PATH_QUERY_PODS_BY_LABEL_APP,
			handler.PARAM_NAMESPACE,
			unittesthelper.TEST_NAMESPACE,
			handler.PARAM_APP,
			unittesthelper.TEST_LABEL_APP)

		code, body, error := unittesthelper.SingleTest(mySuite.router, url)

		if error != nil {
			mySuite.Fail("SingleTest error, %v", error)
			return
		}

		// 检查返回码
		mySuite.EqualValues(http.StatusOK, code)

		// 检查结果
		unittesthelper.Check(&mySuite.Suite, body, unittesthelper.TEST_POD_NUM)
	})
}
  • 点击下图黄色箭头所指按钮,即可开始单元测试
  • 得到结果如下,在没有kubernetes环境的情况下,单元测试通过,所有操作kubernetes的代码均能正常运行
shell 复制代码
=== RUN   TestBasicCrud
=== RUN   TestBasicCrud/TestBasicCrud
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /query_pods_by_label_app  --> client-go-unit-tutorials/handler.QueryPodsByLabelApp (3 handlers)
=== RUN   TestBasicCrud/TestBasicCrud/常规查询
2023/07/02 05:17:27 start SingleTest, request url : /query_pods_by_label_app?namespace=client-go-tutorials&label_app=nginx-app
2023/07/02 05:17:27 query param, namespace [client-go-tutorials], app [nginx-app]
2023/07/02 05:17:27 QueryPodNameByLabelApp, namespace [client-go-tutorials], app [nginx-app]
[GIN] 2023/07/02 - 05:17:27 | 200 |     205.281µs |                 | GET      "/query_pods_by_label_app?namespace=client-go-tutorials&label_app=nginx-app"
--- PASS: TestBasicCrud/TestBasicCrud/常规查询 (0.00s)
--- PASS: TestBasicCrud/TestBasicCrud (0.00s)
--- PASS: TestBasicCrud (0.00s)
PASS
ok      client-go-unit-tutorials/handler        0.034s


> Test run finished at 7/2/2023, 1:17:26 PM <
  • 至此,client-go初级篇已经完成,希望能对刚刚涉及kubernetes开发的读者有所帮助

源码下载

名称 链接 备注
项目主页 github.com/zq2599/blog... 该项目在GitHub上的主页
git仓库地址(https) github.com/zq2599/blog... 该项目源码的仓库地址,https协议
git仓库地址(ssh) [email protected]:zq2599/blog_demos.git 该项目源码的仓库地址,ssh协议
  • 这个git项目中有多个文件夹,本篇的源码在tutorials/client-go-unit-tutorials文件夹下,如下图红框所示:

欢迎关注掘金:程序员欣宸

学习路上,你不孤单,欣宸原创一路相伴...

相关推荐
用户67570498850223 分钟前
告别数据库瓶颈!用这个技巧让你的程序跑得飞快!
后端
千|寻41 分钟前
【画江湖】langchain4j - Java1.8下spring boot集成ollama调用本地大模型之问道系列(第一问)
java·spring boot·后端·langchain
程序员岳焱1 小时前
Java 与 MySQL 性能优化:MySQL 慢 SQL 诊断与分析方法详解
后端·sql·mysql
龚思凯1 小时前
Node.js 模块导入语法变革全解析
后端·node.js
天行健的回响1 小时前
枚举在实际开发中的使用小Tips
后端
wuhunyu1 小时前
基于 langchain4j 的简易 RAG
后端
techzhi1 小时前
SeaweedFS S3 Spring Boot Starter
java·spring boot·后端
写bug写bug2 小时前
手把手教你使用JConsole
java·后端·程序员
苏三说技术2 小时前
给你1亿的Redis key,如何高效统计?
后端