• 打造千万级流量秒杀第十八课 热更新:如何解决程序升级中的稳定性问题?


    上一讲,我给你介绍了 Go 项目的规范,以及如何用 cobra 管理秒杀项目的命令行参数。但是,秒杀项目是个业务系统,是持续不断处理请求的服务。假如我们重启秒杀服务,会有什么样的问题呢?

    服务在停止运行的时候,操作系统会关闭它打开的所有文件描述符,其中包括网络套接字。如果此时有正在处理的请求,由于套接字被关闭,请求会被中断,在用户端看到的将是请求失败,影响用户体验。

    假如从停止运行到重新运行需要耗费 5 秒钟,也就意味着服务在这 5 秒内处于不可用状态。按照 SLA 计算公式,一天内 5 秒不可用,SLA 为 99.994%。假如服务经历两次重启,SLA 将降低到 99.98%。所以,我们要尽可能降低服务重启的时间和次数。

    怎么做呢?可通过实现热更新来解决。热更新包括配置热更新程序热更新,接下来我给你一一介绍下。

    配置热更新

    在做配置热更新前,首先要明白配置项的分类,然后才好有的放矢。一般,秒杀系统中的配置项按加载方式分为两类:启动时加载、运行时加载

    其中,启动时加载的配置也叫固定配置,主要是因为一些配置如果在启动后变更,容易导致程序故障。像秒杀系统中的固定配置,主要有日志等级和 pid 文件路径, MySQL 和 Redis 的地址,admin 和 api 用的监听地址和端口,服务注册和发现以及配置管理用的 etcd 地址,黑名单文件路径,等等。

    具体配置项如下

    [global]
        logLevel = "info"
        pid = "./.pid"
    [mysql]
        address = "127.0.0.1:3306"
        username = "root"
        password = "123"
        database = "seckill"
    [redis]
        address = "127.0.0.1:6379"
        auth = "abcdefg"
    [etcd]
        address = "127.0.0.1:2379"
    [api]
        bind = "127.0.0.1:8080"
        blacklist = "./blacklist.txt"
    [admin]
        bind = "127.0.0.1:8081"
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    注意,以上各配置项的值是本地开发环境的值。如果是测试环境、生产环境等服务器环境,则需要配置成与这些环境相应的值。
    运行时加载配置项,就是希望在程序运行过程中能够动态变更某些配置,也就是热更新。热更新通常有三种方法:定时器法、事件驱动法、接口推送法。

    定时器法是定时从配置文件、Redis、MySQL 等可以存储配置的地方拉取配置,并更新到本地内存缓存中。事件驱动法是通过监控配置变更的事件,由事件触发拉取配置,相比定时器法,它有实时、性能消耗低等特点。接口推送法是指程序实现同步配置的接口,由其他程序或者工具通过调用该程序的接口,将配置发送到该程序并缓存到内存中,它通常适用于调试某个节点的情况下。

    在这三种方法里,秒杀系统主要用到事件驱动法和接口推送法,涉及三类数据:活动配置数据、黑名单、集群信息等。这一讲主要介绍采用事件驱动法热更新黑名单和集群信息,采用接口推送法更新活动配置数据我将会在第 21 ~ 23 讲详细介绍。

    如何热更新黑名单?

    黑名单文件主要由数据分析系统生成,并由文件同步工具分发到秒杀 API 服务节点上,然后由秒杀 API 服务加载到内存中,以便实现反黄牛功能。

    黑名单中的数据主要是黄牛用户 ID,以及少量恶意用户 ID。数据量可能达到几十兆字节,甚至是上百兆字节。由于数据量较大,如果从 MySQL、Redis 这类存储中读取的话,可能耗时较长,影响性能和稳定性。因此,黑名单数据需要从本地磁盘加载。

    如何做到监控黑名单文件被修改并及时加载到内存中呢?

    操作系统使用 inode 维护文件信息,当有程序调用系统函数创建、修改、删除文件的时候,操作系统会修改 inode 中的数据,触发 inode 事件。如果我们能监控 inode 事件,也就能利用事件驱动来将文件数据更新到内存中。

    在配置管理工具 viper 中,有个函数 WatchConfig,它的作用便是监控配置文件的 inode 事件,并将最新的配置从配置文件中读取到内存中。此外,viper 还提供了另一个函数 OnConfigChange ,你可以用它注入一个回调函数,当配置文件变更的时候,viper 在 WatchConfig 函数中会调用你注入的回调函数。

    具体要怎么做呢?

    第一步,你需要实现一个函数 WatchBlacklist ,用于初始化事件监控;

    第二步,为了接收配置文件事件,你需要实现一个回调函数 onBlacklistChange;

    第三步,实现一个 updateBlacklist 函数,并在回调函数中调用 updateBlacklist 函数,以便更新本地内存中的数据。

    具体示例代码如下:

    func WatchBlacklist() {
       v := viper.New()
       v.SetConfigFile(viper.GetString("blacklist.filePath"))
       v.OnConfigChange(onBlackListChange)
       go v.WatchConfig()
    }
    func onBlacklistChange(in fsnotify.Event) {
       const writeOrCreateMask = fsnotify.Write | fsnotify.Create
       if in.Op&writeOrCreateMask != 0 {
          updateBlacklist()
       }
    }
    func updateBlacklist() {
       // TODO: do update
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    在 WatchBlacklist 函数中,我们创建一个新的 viper 对象,并通过调用它的 SetConfigFile 方法设置了配置文件路径。然后通过调用它的 OnConfigChange 函数,注册了我们实现的事件回调函数 onBlacklistChange。在 onBlacklistChange 中,我们判断事件的类型是否为创建或者修改文件,如果是的话,就调用 updateBlacklist 函数更新内存中的数据。

    如何热更新集群信息?

    集群信息一般存储在 etcd 中,它包括服务节点信息、日志等级、降级开关、限流限速、连接池大小等。**在热更新方面,秒杀系统中的集群信息如果使用 viper 会更简单。**我们可以将秒杀系统集群信息保存在 etcd 中的 /seckill/config/ 这个 key 下,然后用 viper 来实现配置更新的示例代码。

    具体思路是:创建一个 viper 对象 clusterCfgViper,并实现一个 WatchClusterConfig 函数;在函数中调用 clusterCfgViper 的 AddRemoteProvider 方法,传入 etcd 相关的地址、key 等;最后调用 clusterCfgViper 的 WatchRemoteConfigOnChannel 方法,实时监控配置变更并更新到内存缓存。这样,我们就可以在后续代码中,通过调用 clusterCfgViper 的 GetString 等方法获取最新的配置。

    具体代码如下:

    var clusterCfgViper = viper.New()
    func WatchClusterConfig() error {
       if err := clusterCfgViper.AddRemoteProvider("etcd", viper.GetString("etcd.address"), "/seckill/config"); err != nil {
          logrus.Error("add remote provider failed, error ", err)
          return err
       }
       return clusterCfgViper.WatchRemoteConfigOnChannel()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    viper 支持很多种配置管理,etcd 只是其中之一。如果你有时间,建议你好好学习下这个优秀开源库的源码,你将会有很大的收获。

    程序热更新

    前面我提到,程序重启的时候会导致程序的所有连接都中断,从而导致正在处理中的请求失败。虽然我们解决了配置的热更新,但程序升级的时候,必须要停掉老版本程序,运行新版本程序。如何让新版本程序平稳地替换运行中的老版本,就是程序热更新所要解决的核心问题。

    程序热更新步骤

    程序热更新的大致流程是这样的:

    首先,为了后期让老版本程序平稳切换到新版本程序,在启动时我们就可以把端口设置为可重复监听,以便让新版本程序及时接收请求;

    然后,在发布新版本的时候,先启动新版本程序,与老版本程序监听相同端口;

    接下来,通过信号机制通知老版本停止监听端口,所有新请求由新版本接收并处理;

    最后,老版本等待所有处理中的请求处理完,并关闭所有连接,然后退出。

    代码实现举例

    以 API 服务为例,具体到代码中,我们可以在程序启动的时候,在 api.Run 中设置端口为可重复监听。注册一个信号通知通道 onSignal 和服务退出通道 onExit,在从 onSignal 接收到信号后,执行 api.Exit 函数

    示例代码如下:

    onExit := make(chan error)
    go func() {
       if err := api.Run(); err != nil {
          logrus.Error(err)
          onExit <- err
       }
       close(onExit)
    }()
    onSignal := make(chan os.Signal)
    signal.Notify(onSignal, syscall.SIGINT, syscall.SIGTERM)
    select {
    case sig := <-onSignal:
       api.Exit()
       logrus.Info("exit by signal ", sig)
    case err := <-onExit:
       logrus.Info("exit by error ", err)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    api.Run 函数中又是如何做到端口可重复监听的呢?具体思路是:先创建一个 ListenConfig 对象 lisCfg,利用它的 Control 属性设置一个 Control 函数;在 Control 函数里面调用系统函数 SetsockoptInt 将端口设置为可重复监听,需要确保系统参数 net.ipv4.tcp_tw_reuse=1;然后调用 lisCfg 的 Listen 方法创建一个 Listener 对象 lis;最后创建一个 gin 对象 g 并调用它的 RunListener 监听 lis。

    为了给老版程序发信号,并更新到新版程序,我们需要实现一个 updateProc 函数。该函数获取到老版本的 pid,也就是进程 id,每隔一段时间获取其状态并发送信号,通知老版本退出,然后将文件中的 pid 更新为新版本的 pid。

    为了让老版本程序不再接收请求,在 api.Exit 函数中,我们将 lis 关闭,并等待一段时间,让老版程序将进行中的请求处理完。

    具体代码如下:

    var lis net.Listener
    func Run() error {
       var err error
       bind := viper.GetString("api.bind")
       lisCfg := &net.ListenConfig{
          Control: func(network, address string, c syscall.RawConn) error {
             var err error
             err1 := c.Control(func(fd uintptr) {
                err = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, unix.SO_REUSEPORT, 1)
             })
             if err == nil {
                err = err1
             }
             return err
          },
          KeepAlive: 0,
       }
       lis, err = lisCfg.Listen(context.Background(), "tcp", bind)
       if err != nil {
          return err
       }
       g := gin.New()
       // TODO: init routers
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    // 更新程序,给老版本发送信号
    go updateProc()
    return g.RunListener(lis)
    }
    func updateProc() {
    if pidFile, err := os.Open(viper.GetString(“api.pid”)); err == nil {
    pidBytes, _ := ioutil.ReadAll(pidFile)
    pid, _ := strconv.Atoi(string(pidBytes))
    if pid > 0 {
    // 为了避免因某些原因老版本程序无法退出,尝试发送多个信号,最后一次 SIGKILL 将强制结束老版程序
    signals := []syscall.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGKILL}
    if proc, err := os.FindProcess(pid); err == nil {
    for _, sig := range signals {
    if err = proc.Signal(sig); err != nil {
    break
    }
    var stat *os.ProcessState
    // 等待老版程序退出
    stat, err = proc.Wait()
    if err != nil || stat.Exited() {
    break
    }
    }
    }
    }
    pidFile.Close()
    }
    if pidFile, err := os.Create(viper.GetString(“api.pid”)); err == nil {
    pid := os.Getpid()
    pidFile.Write([]byte(strconv.Itoa(pid)))
    pidFile.Close()
    }
    }
    func Exit() {
    lis.Close()
    // TODO: 等待请求处理完
    // time.Sleep(10 * time.Second)
    }

    然后我们执行 make 命令将程序编译出来并运行,当启动第二个程序的时候,第一个收到了信号并退出。以下是效果图。

    Drawing 0.png

    Drawing 1.png

    小结

    这一讲我主要介绍了热更新解决的核心问题,以及配置热更新和程序热更新的原理和实现。

    正如我前面所说的,热更新有很多种方法,你需要学会在工作中为项目选取合适的热更新方法。不同的架构,采用的方法不一样。假如现有基础组件中没有 etcd、zookeeper 之类的组件,那么你应该优先选择定时器法,而不是为了这一个服务增加一套 etcd 或者 zookeeper。

    思考题:定时器法更新配置是如何实现的?

    欢迎你在留言区留下答案哦。好了,这一讲就到这里了,下一讲我将为你介绍“如何使用 RESTFul 和 RPC 实现 API ”。到时见!

    代码链接:

    黑名单热更新代码:

    https://github.com/lagoueduCol/MiaoSha-Yiletian/blob/main/infrastructure/utils/blacklist.go

    集群信息热更新代码:

    https://github.com/lagoueduCol/MiaoSha-Yiletian/blob/main/infrastructure/utils/cluster.go

    程序热更新代码:

    https://github.com/lagoueduCol/MiaoSha-Yiletian/blob/main/interfaces/api/api.go

    https://github.com/lagoueduCol/MiaoSha-Yiletian/blob/main/cmd/api.go


    精选评论

  • 相关阅读:
    web3.0的特点、应用和安全问题
    DolpinScheduler
    昆明航空x-s3-s4e算法分析
    MariaDB落幕和思考
    Java中static关键字的使用与练习
    【大模型系列】指令微调
    Kubernetes单主集群的部署(一)
    target is not existed: .page-component__scroll .el-scrollbar__wrap
    图的几个基本概念:连通图、强连通图、完全图等
    JDBC 在性能测试中的应用
  • 原文地址:https://blog.csdn.net/fegus/article/details/126361500