在当今云计算与微服务盛行的时代,分布式任务系统已成为支撑大规模业务的核心基础设施。今天就来为大家分享下如何基于 Go 语言从零设计和实现一个架构简洁且扩展性强的分布式任务系统。

前置概念

本文会设计并实现一个分布式任务系统,这里我们要先明确两个概念。

  • 分布式:在我们将要实现的分布式任务系统中,分布式是指我们的服务可以部署多个副本,这样才能确保服务更加稳定。
  • 任务:这里的任务是指异步任务,可能是定时或需要周期性运行的任务。

有了这两个前置概念,我们再来分析下在 Go 中如何实现分布式和如何处理异步任务。

异步任务

在 Go 中,要处理异步任务有多种方式,比如原生支持的 time.Sleeptime.Timertime.Ticke,再比如一些第三方包 go-co-op/gocron/v2robfig/cron/v3bamzi/jobrunner 等。本项目在调研过后决定采用 robfig/cron/v3 包(以下简称 cron)来处理异步任务,原因如下:

  • cron 是一个非常流行的包,支持标准 crontab 表达式(并且可精确到秒),支持时区、任务链等高级功能。
  • 提供秒级精度的任务调度。
  • 轻量级,且<font style="color:rgb(33, 33, 33);">能轻松应对各种复杂的定时任务场景</font>。

对于 cron 包的使用,可以参考我的另一篇文章「在 Go 中使用 cron 执行定时任务」,里面有详细说明。

分布式

既然我们的任务系统是分布式的,那么必然要考虑并发安全问题。当多个副本同时读写系统资源时,很容易产生竞太问题。在分布式场景中,解决竞太问题最常用的手段当然是分布式锁。

Go 中的分布式锁解决方案也很多,常见的有基于 etcd、Redis、ZooKeeper 等中间件来实现的,因为 Redis 在系统中更加常用,所以本项目采用基于 Redis 实现分布式锁的解决方案。Go 中有两个比较常用的第三方包 bsm/redislockgo-redsync/redsync 都是基于 Redis 的分布式锁实现。本项目在调研过后决定采用 go-redsync/redsync 包(以下简称 redsync),原因如下:

  • redsync 遵循 Redis 官方推荐的 Redlock 算法,支持多节点,容忍部分节点故障,避免单点问题。
  • 通过多数派机制确保锁的全局唯一性,降低锁冲突风险。
  • redsyncRedis 官方 唯一推荐的 Go Redis 分布式锁解决方案,由 Redis 社区背书,长期维护,可靠性高。

对于 redsync 包的使用,可以参考我的另一篇文章「在 Go 中如何使用分布式锁解决并发问题?」,里面有详细说明。

分布式任务系统

现在我们对分布式任务系统中的分布式和任务都有了明确的认识,并且找到了解决方案。那么接下来就可以设计并实现分布式任务系统了。

功能介绍

我们要实现的分布式任务系统叫 nightwatch,nightwatch 是守夜、值班的意思,那么这套系统的功能也就一目了然了,就是用来 24 小时不停机的执行异步任务的。

nightwatch 要实现的主要功能如下:

现在我们有一个系统,用户可以在 Web 页面通过表单提交一个“任务”到关系型数据库的任务表中。然后 nightwatch 系统会定时的扫描任务表,取出待执行的任务,并根据任务中的配置,到 Kubernetes 中拉起 Job 资源对象,真正的执行任务。此外,nightwatch 还会取出已经开始执行的任务,然后去 Kubernetes 中获取当前任务对应的 Job 实时状态,并回写到数据库中。直到 Kubernetes 中的 Job 执行完成(或失败),nightwatch 会标记 Job 在数据库表中的任务状态为完成(或失败)。当任务状态为完成(或失败),则任务任务终止,nightwatch 不再扫描出这种状态的数据。

系统整体架构如下:

image.png

nightwatch 是系统中一个非常核心的组件,用来控制任务的执行,并同步任务状态。

架构设计

现在我们知道了 nightwatch 的作用,那么就可以设计其实现架构了。

nightwatch 架构设计如下:
image.png

首先,我们需要思考一个问题,分布式锁应该在何时使用?

在分布式任务系统中,我们有两种方式使用分布式锁来保证并发安全。一种是在执行具体的定时任务时,多个副本之间进行竞争,谁抢到锁,谁就可以执行任务,未抢到锁的副本可以选择性的跳过此次执行周期。另一种是在 nigthwatch 启动时,就开始抢锁,多个副本之间谁抢到锁,谁就去执行任务调度,未抢到锁的副本则进行周期性的尝试抢锁操作,如果当前执行任务调度的副本被终止,那么其他副本就有机会抢到锁,并执行任务调度。

这两种方式个各自有不同的使用场景,第一种方式的优势是能够实现多副本之间的负载均衡,多个副本都在工作,都有可能抢到锁并执行任务,不过这种方式不能严格控制执行任务的间隔时间,比较适合对间隔时间要求不严格的任务。第二种方式实际上只有一个副本在执行任务调度,其他副本是空载状态,是主备设计,这种方式的好处是能够严格控制任务执行的间隔时间。

nigthwatch 采用第二种方式来使用分布式锁保证并发安全。所以在 nigthwatch 的架构设计中,在启动 nigthwatch 时,先将所有的定时任务注册到任务调度器中,接着就会进行抢锁操作,只有抢到锁的副本才能够执行任务调度。未抢到锁,则使用一个循环周期性的尝试抢锁,直到抢锁成功。对于抢到锁的副本,当注册的任务定时策略达到时,任务调度器就会执行任务。架构图中的 task 就是我们要实现的异步任务,也是主要业务逻辑,task 组件会从数据库表中读取任务,然后在 Kubernetes 中启动 Job,并同步数据库和 Kubernetes 资源之间的状态。

目录结构

我们现在已经设计好了 nigthwatch 的架构,可以动手进行开发实现了。

以下是 nigthwatch 项目的目录和文件:

$ tree nightwatch
nightwatch # 项目目录
├── README.md # README 文件
├── assets # 项目相关的资源目录
│   ├── docker-compose.yaml # 用于启动项目依赖的 MariaDB 和 Redis
│   └── schema.sql # 测试数据 SQL
├── cmd # 项目启动入口
│   └── main.go
├── go.mod
├── go.sum
├── internal # 项目内部包
│   ├── logger.go # 定制日志
│   ├── nightwatch.go # nightwatch 的实现和启动入口
│   └── watcher # 任务接口和实现
│       ├── all # 任务注册入口
│       │   └── all.go
│       ├── config.go # 任务配置
│       ├── task # 任务实现,一个可以定时同步 MariaDB 和 Kubernetes 任务状态的示例程序
│       │   ├── task.go
│       │   └── watcher.go
│       └── watcher.go # 任务接口
└── pkg # 项目公共包
    ├── db # 数据库实例
    │   ├── mysql.go
    │   └── redis.go
    ├── meta # 元信息
    │   └── where.go # MariaDB where 查询条件元信息封装
    ├── model # 任务模型
    │   └── task.go
    ├── store # 数据库操作接口
    │   ├── helper.go
    │   ├── store.go
    │   └── task.go
    └── util # 工具包
        └── reflect
            └── reflect.go

14 directories, 21 files

这里主要的目录和文件我都标明了其用途,不必完全记住,你先有个印象,大概知道整个项目的结构。

调用链路

为了便于你理解代码,我画了一张 nigthwatch 项目的调用链路图:

image.png

这个调用链路图指明了 nigthwatch 项目中所有目录之间的代码调用关系。根据这张图,可以看出这是一个非常简洁的架构。cmd 中的入口函数 main 会调用 internal 中的 nigthwatch 包,nigthwatch 是分布式系统实现的关键所在,这里实现了任务的注册和调度,watcher 定义了任务的接口,task 就是任务的具体实现,task 的业务逻辑中会依赖 store 层来读写数据库,所以 store 会依赖 modeldb

代码实现

接下来就进入到真正的编码阶段了。

首先我们需要为 nigthwatch 项目的业务设计一张任务表,建表 SQL 语句如下:

https://github.com/jianghushinian/blog-go-example/blob/main/nightwatch/assets/schema.sql
CREATE TABLE IF NOT EXISTS `task` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(45) NOT NULL DEFAULT '' COMMENT '任务名称',
  `namespace` varchar(45) NOT NULL DEFAULT '' COMMENT 'k8s namespace 名称',
  `info` TEXT NOT NULL COMMENT '任务 k8s 相关信息',
  `status` varchar(45) NOT NULL DEFAULT '' COMMENT '任务状态',
  `user_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '用户 ID',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_name_namespace` (`name`, `namespace`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务表';

为了简化你理解项目的成本,这里仅定义了最小需要字段。

同时我们可以插入两条测试数据,用来后续项目功能的验证:

INSERT INTO `task` (`id`, `name`, `namespace`, `info`, `status`, `user_id`) VALUES (1, 'demo-task-1', 'default', '{"image":"alpine","command":["sleep"],"args":["60"]}', 'Normal', 1);
INSERT INTO `task` (`id`, `name`, `namespace`, `info`, `status`, `user_id`) VALUES (2, 'demo-task-2', 'demo', '{"image":"busybox","command":["sleep"],"args":["3600"]}', 'Normal', 2);

ID1task 数据举例,任务名是 demo-task-1namespacedefault,镜像是 alpine,执行命令是 sleep,命令参数是 60,状态为 Normal 表示待执行。当 nigthwatch 服务扫描到这条数据时,就会在 Kubernetes 中 default 这个 namespace 下创建一个 namedemo-task-1 的 Job,其启动镜像为 alpine,启动命令为 sleep 60,即睡眠 60 秒然后退出。

现在有了数据库表和测试数据,我们来看看 nigthwatch 代码是如何实现的。

入口文件 cmd/main.go 实现如下:

https://github.com/jianghushinian/blog-go-example/blob/main/nightwatch/cmd/main.go
package main

import (
    "flag"
    "log/slog"
    "path/filepath"
    "time"

    genericapiserver "k8s.io/apiserver/pkg/server"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/tools/clientcmd"
    "k8s.io/client-go/util/homedir"

    "github.com/jianghushinian/blog-go-example/nightwatch/internal"
    "github.com/jianghushinian/blog-go-example/nightwatch/pkg/db"
)

func main() {
    slog.SetLogLoggerLevel(slog.LevelDebug)

    var kubecfg *string
    if home := homedir.HomeDir(); home != "" {
        kubecfg = flag.String("kubeconfig", filepath.Join(home, ".kube", "config"), "Optional absolute path to kubeconfig")
    } else {
        kubecfg = flag.String("kubeconfig", "", "Absolute path to kubeconfig")
    }

    config, err := clientcmd.BuildConfigFromFlags("", *kubecfg)
    if err != nil {
        slog.Error(err.Error())
        return
    }
    config.QPS = 50
    config.Burst = 100
    clientset, err := kubernetes.NewForConfig(config)
    if err != nil {
        slog.Error(err.Error())
        return
    }

    cfg := nightwatch.Config{
        MySQLOptions: &db.MySQLOptions{
            Host:                  "127.0.0.1:33306",
            Username:              "root",
            Password:              "nightwatch",
            Database:              "nightwatch",
            MaxIdleConnections:    100,
            MaxOpenConnections:    100,
            MaxConnectionLifeTime: time.Duration(10) * time.Second,
        },
        RedisOptions: &db.RedisOptions{
            Addr:         "127.0.0.1:36379",
            Username:     "",
            Password:     "nightwatch",
            Database:     0,
            MaxRetries:   3,
            MinIdleConns: 0,
            DialTimeout:  5 * time.Second,
            ReadTimeout:  3 * time.Second,
            WriteTimeout: 3 * time.Second,
            PoolSize:     10,
        },
        Clientset: clientset,
    }

    nw, err := cfg.New()
    if err != nil {
        slog.Error(err.Error())
        return
    }

    stopCh := genericapiserver.SetupSignalHandler()
    nw.Run(stopCh)
}

因为 main.go 非常重要,是整个程序的入口,所以我就把完整代码都贴出来了,包括 import 部分,这是为了让你对项目文件之间的依赖关系有一个更清晰的认知,后续讲解的其他模块我就只会贴出核心代码。

main 函数的核心功能如下:

首先会初始化各种依赖包,初始化 Kubernetes clientset 用于后续操作 Job,初始化 MySQL 用于从中读取任务和更新任务状态,初始化 Redis 用于实现分布式锁。接着会使用这些初始化的对象创建一个配置对象 nightwatch.Config。然后使用 cfg.New() 创建一个 nightwatch 实例对象 nw。最后调用 nw.Run(stopCh) 启动服务。这里为了做优雅退出,还引用了 Kubernetes genericapiserver 优雅退出机制,你可以参考我的文章「Go 程序如何实现优雅退出?来看看 K8s 是怎么做的——上篇」、「Go 程序如何实现优雅退出?来看看 K8s 是怎么做的——下篇」查看其实现原理。

这里涉及到的 Kubernetes clientset、MySQL 和 Redis 相关的具体配置细节我就不详细讲解了,咱们还是将主要精力聚焦在 nigthwatch 的主脉络上。

接下来看下 cfg.New() 代码实现如下:

https://github.com/jianghushinian/blog-go-example/blob/main/nightwatch/internal/nightwatch.go
// New 通过配置构造一个 nightWatch 对象
func (c *Config) New() (*nightWatch, error) {
    rdb, err := db.NewRedis(c.RedisOptions)
    if err != nil {
        slog.Error(err.Error(), "Failed to create Redis client")
        return nil, err
    }

    logger := newCronLogger()
    runner := cron.New(
        cron.WithSeconds(),
        cron.WithLogger(logger),
        cron.WithChain(cron.SkipIfStillRunning(logger), cron.Recover(logger)),
    )

    pool := goredis.NewPool(rdb)
    lockOpts := []redsync.Option{
        redsync.WithRetryDelay(50 * time.Microsecond),
        redsync.WithTries(3),
        redsync.WithExpiry(defaultExpiration),
    }
    locker := redsync.New(pool).NewMutex(lockName, lockOpts...)

    cfg, err := c.CreateWatcherConfig()
    if err != nil {
        return nil, err
    }

    nw := &nightWatch{runner: runner, locker: locker, config: cfg}
    if err := nw.addWatchers(); err != nil {
        return nil, err
    }

    return nw, nil
}

*Config.New 方法会通过配置信息构造一个 nightWatch 对象并返回。这里的 runner 就是异步任务调度器,使用 cron 包实现,用来调度和执行定时任务。并且这个方法内部还实例化了一个 redsync 分布式锁对象 lockernightWatch 对象就是通过 runnerlockercfg 来构造的。

这里的核心部分是 addWatchers 的逻辑,其实现如下:

https://github.com/jianghushinian/blog-go-example/blob/main/nightwatch/internal/nightwatch.go
// 注册所有 Watcher 实例到 nightWatch
func (nw *nightWatch) addWatchers() error {
    for n, w := range watcher.ListWatchers() {
        if err := w.Init(context.Background(), nw.config); err != nil {
            slog.Error(err.Error(), "Failed to construct watcher", "watcher", n)
            return err
        }

        spec := watcher.Every3Seconds
        if obj, ok := w.(watcher.ISpec); ok {
            spec = obj.Spec()
        }

        if _, err := nw.runner.AddJob(spec, w); err != nil {
            slog.Error(err.Error(), "Failed to add job to the cron", "watcher", n)
            return err
        }
    }

    return nil
}

*nightWatch.addWatchers 方法用来注册所有 Watcher 对象到调度器 runner 中。

Watcher 是一个接口,定义了异步任务应该实现的方法。Watcher 接口定义如下:

https://github.com/jianghushinian/blog-go-example/blob/main/nightwatch/internal/watcher/watcher.go
type Watcher interface {
    Init(ctx context.Context, config *Config) error
    cron.Job
}

type ISpec interface {
    Spec() string
}

var (
    registryLock = new(sync.Mutex)
    registry     = make(map[string]Watcher)
)

func Register(watcher Watcher) {
    registryLock.Lock()
    defer registryLock.Unlock()

    name := reflectutil.StructName(watcher)
    if _, ok := registry[name]; ok {
        panic("duplicate watcher entry: " + name)
    }

    registry[name] = watcher
}

func ListWatchers() map[string]Watcher {
    registryLock.Lock()
    defer registryLock.Unlock()

    return registry
}

可以看到,要实现一个异步任务,需要实现 Init 方法以及 cron.Job 接口。cron.Job 接口其实只有一个方法定义如下:

type Job interface {
    Run()
}

只要满足 Watcher 接口的任务,就可以通过 Register 函数注册到 registry 中。ListWatchers 函数则可以返回注册到 registry 中全部任务。而 ListWatchers 函数正是在前文讲解的 *nightWatch.addWatchers 方法中调用的。

到目前为止,任务如何被注册到 nightWatch.runner 的过程我们就串起来了。接下来需要关注的两个点是,调度器 runner 是何时启动的,以及是何时调用 Register 函数注册任务的。

我们先来看调度器 runner 是何时启动的:

https://github.com/jianghushinian/blog-go-example/blob/main/nightwatch/internal/nightwatch.go
// Run 执行异步任务,此方法会阻塞直到关闭 stopCh
func (nw *nightWatch) Run(stopCh <-chan struct{}) {
    ctx := wait.ContextForChannel(stopCh)

    // 循环加锁,直到加锁成功,再去启动任务
    ticker := time.NewTicker(defaultExpiration + (5 * time.Second))
    defer ticker.Stop()
    for {
        err := nw.locker.LockContext(ctx)
        if err == nil {
            slog.Debug("Successfully acquired lock", "lockName", lockName)
            break
        }
        slog.Debug("Failed to acquire lock", "lockName", lockName, "err", err)
        <-ticker.C
    }

    // 看门狗,实现锁自动续约
    ticker = time.NewTicker(extendExpiration)
    defer ticker.Stop()
    go func() {
        for {
            select {
            case <-ticker.C:
                if ok, err := nw.locker.ExtendContext(ctx); !ok || err != nil {
                    slog.Debug("Failed to extend lock", "err", err, "status", ok)
                }
            case <-ctx.Done():
                slog.Debug("Exiting lock watchdog")
                return
            }
        }
    }()

    // 启动定时任务
    nw.runner.Start()
    slog.Info("Successfully started nightwatch server")

    // 阻塞等待退出信号
    <-stopCh

    nw.stop()
}

*nightWatch.Run 方法中,首先会启动一个无限循环,定时执行尝试抢锁操作,直到抢锁成功。这与前文中讲解的 nightwatch 架构设计是一致的。抢到锁后,就可以执行 nw.runner.Start() 启动调度器,执行定时任务了。

此外,在 nightwatch 架构图中没有体现的一点是,这里为分布式锁实现了看门狗机制,用来自动续约。关于 redsync 分布式锁的自动续约,在我的文章「在 Go 中如何使用分布式锁解决并发问题?」中有详细讲解。

而这个 Run 方法,就是在 main 函数中通过 nw.Run(stopCh) 调用的。

我们还剩下一个最后要看的核心逻辑是 task 在何时会调用 Register 注册到 registry 变量中。

还记得前文中讲解的 Watcher 接口么,*taskWatcher 实现了这个接口:

https://github.com/jianghushinian/blog-go-example/blob/main/nightwatch/internal/watcher/task/watcher.go
var _ watcher.Watcher = (*taskWatcher)(nil)

type taskWatcher struct {
    store     store.IStore
    clientset kubernetes.Interface

    wg sync.WaitGroup
}

func (w *taskWatcher) Init(ctx context.Context, config *watcher.Config) error {
    w.store = config.Store
    w.clientset = config.Clientset
    return nil
}

func (w *taskWatcher) Spec() string {
    return "@every 30s"
}

func init() {
    watcher.Register(&taskWatcher{})
}

taskWatcher 就是 task 任务的具体对象,它实现了 watcher.Watcher 接口。可以发现,Register 函数是在 init 函数中调用的,即 task 包被导入时实现自动注册。

task 包会在 nightwatch/internal/watcher/all/all.go 文件被导入:

https://github.com/jianghushinian/blog-go-example/blob/main/nightwatch/internal/watcher/all/all.go
package all

import (
    // 触发所有 Watcher 的 init 函数进行注册
    _ "github.com/jianghushinian/blog-go-example/nightwatch/internal/watcher/task"
)

这里以匿名包的方式导入 task 包。如果我们还有其他的任务实现,则同样可以参考 task 包的注册方式,在这里以匿名包形式导入,这也是 all 包名的由来,可以注册全部的任务。

然后会在 nightwatch 中再次以匿名包的方式导入 all 包:

https://github.com/jianghushinian/blog-go-example/blob/main/nightwatch/internal/nightwatch.go
package nightwatch

import (
    ...
    // 触发 init 函数
    _ "github.com/jianghushinian/blog-go-example/nightwatch/internal/watcher/all"
)

我们可以总结出任务的注册流程是,nightwatch 包导入 all 包,all 包会导入 task 包,task 包的 init 函数执行就会完成注册。所以在入口文件 main.go 导入 nightwatch 包的时候,就会触发任务的注册。在调用 nw.Run(stopCh) 启动服务时,所有的任务已经注册完成了。

taskWatcher 对象的核心逻辑当然就是 Run 方法了:

https://github.com/jianghushinian/blog-go-example/blob/main/nightwatch/internal/watcher/task/watcher.go
// Run 运行 task watcher 任务
func (w *taskWatcher) Run() {
    w.wg.Add(2)

    slog.Debug("Current sync period is start")

    // NOTE: 将 Normal 状态任务在 Kubernetes 中启动
    go func() {
        defer w.wg.Done()
        ctx := context.Background()

        _, tasks, err := w.store.Tasks().List(ctx, meta.WithFilter(map[string]any{
            "status": model.TaskStatusNormal,
        }))
        if err != nil {
            slog.Error(err.Error(), "Failed to list tasks")
            return
        }

        var wg sync.WaitGroup
        wg.Add(len(tasks))
        for _, task := range tasks {
            go func(task *model.Task) {
                defer wg.Done()
                job, err := w.clientset.BatchV1().Jobs(task.Namespace).Create(ctx, toJob(task), metav1.CreateOptions{})
                if err != nil {
                    slog.Error(err.Error(), "Failed to create job")
                    return
                }

                task.Status = model.TaskStatusPending
                if err := w.store.Tasks().Update(ctx, task); err != nil {
                    slog.Error(err.Error(), "Failed to update task status")
                    return
                }
                slog.Info("Successfully created job", "namespace", job.Namespace, "name", job.Name)
            }(task)
        }
        wg.Wait()
    }()

    // NOTE: 同步中间状态的任务在 Kubernetes 中的状态到表中
    go func() {
        defer w.wg.Done()
        ctx := context.Background()

        _, tasks, err := w.store.Tasks().List(ctx, meta.WithFilterNot(map[string]any{
            // 排除这几个状态
            "status": []model.TaskStatus{model.TaskStatusNormal, model.TaskStatusSucceeded, model.TaskStatusFailed},
        }))
        if err != nil {
            slog.Error(err.Error(), "Failed to list tasks")
            return
        }

        var wg sync.WaitGroup
        wg.Add(len(tasks))
        for _, task := range tasks {
            go func(task *model.Task) {
                defer wg.Done()
                job, err := w.clientset.BatchV1().Jobs(task.Namespace).Get(ctx, task.Name, metav1.GetOptions{})
                if err != nil {
                    slog.Error(err.Error(), "Failed to get task")
                    return
                }

                task.Status = toTaskStatus(job)
                if err := w.store.Tasks().Update(ctx, task); err != nil {
                    slog.Error(err.Error(), "Failed to update task status")
                    return
                }
                slog.Info("Successfully sync job status to task", "namespace", job.Namespace, "name", job.Name, "status", task.Status)
            }(task)
        }
        wg.Wait()
    }()

    w.wg.Wait()
    slog.Debug("Current sync period is complete")
}

Run 方法就是用来实现每个 watcher 对象的业务逻辑。比如这里就实现了 task 任务的业务逻辑,它包含两个功能,在 Run 方法的上半部分代码中启动了第一个 goroutine 用来实现将 Normal 状态任务在 Kubernetes 中启动,下半部分代码中启动了第二个 goroutine 用来实现同步已运行的任务在 Kubernetes 中的 Job 状态到数据库表中。

至此,nightwatch 项目就讲解完成了。我们一起实现了一个架构简洁且扩展性强的分布式任务系统。关于 nightwatch 项目中更多的代码细节你可以跳转到我的 GitHub 仓库中查看。

总结

本文带大家从技术选型到架构设计再到代码实现,一步步完成了一个简洁优雅的分布式任务系统。这套系统不仅架构简洁,扩展也非常方便,我们只需要按照 task 的套路实现更多的异步任务,都可以非常方便的方式注册到 nightwatch 中。

我们实现的 nightwatch 项目实际上是开源项目 OneX 项目中 onex-nightwatch 组件的 copy 实现,其架构完全参考 onex-nightwatch 实现。本文介绍的 nightwatch 项目只是 OneX 其中的一个组件,OneX 所有源代码的质量都超级高,并且相当规范,全部都是来自一线大厂的实践经验。

OneX 不仅是一个开源项目,更是一整套 Go + Kubernetes + LLMOps + MLOps 企业级落地方案,这个项目出自孔令飞老师的「云原生 AI 实战星球」。

OneX 项目技术栈如下:

image.png

OneX 项目完全开源免费,如果你想学习各种最佳实践,非常建议你深入学习这个项目。如果你觉得自己一个人学习容易放弃,或者遇到问题无法解决,想找人一起讨论,也欢迎你加入到孔令飞老师的「云原生 AI 实战星球」。这里有一起进步的小伙伴,星球是围绕 OneX 打造的,里面会详细讲解 OneX 中用到的各种技术栈,极大缩短自学 OneX 项目的时间。

星球刚刚上线,原价 ¥1599,现在早鸟价 ¥1299,交个朋友,可以加我微信领取折扣优惠券(限时限量),星球值不值你可以自己先研究下 OneX 项目一探虚实。

扫码直达星球:

image.png

扫码加我微信领取星球大额优惠券(限时限量):

image.png

本文示例源码我都放在了 GitHub 中,欢迎点击查看。

希望此文能对你有所启发。

延伸阅读

联系我


江湖十年
89 声望22 粉丝