在 Go 中构建可扩展的多租户应用程序【译】

原文链接 https://atlasgo.io/blog/2025/05/26/gophercon-scalable-multi-tenant-apps-in-go 为 GopherCon Israel 2025 准备并呈现。 引言 在本篇博客中,我们将基于我们在构建 Atlas Cloud 后端(作为我们商业产品的一部分)的经验,探讨在 Go 中构建可扩展多租户应用程序的不同策略。 但首先,让我们明确一下我们所说的多租户应用是什么。 多租户是一个系统的特性,即单个实例为多个客户(租户)提供服务。 作为一家商业企业,你的目标当然是有很多客户!但你想要服务许多客户,他们期望有一个流畅无缝的体验,就好像只有他们在使用你的服务一样。 你向客户隐含做出的两个重要承诺是: 数据隔离:每个租户的数据都是隔离和安全的,确保一个租户无法访问另一个租户的数据。 性能:无论租户数量如何,应用程序都应表现良好,确保一个租户的使用不会降低其他租户的体验。 让我们探讨一些可能实现这些承诺的方法。 物理隔离 确保数据和性能隔离最直接的方法是为每个租户运行一个独立的应用实例。这种方法通常被称为“物理隔离”或“专用实例”。 为每个租户运行独立实例,可以确保: 每个租户的数据存储在独立的数据库中,从而保证完全隔离。如有需要,租户可以在不同的 VPC 中运行,甚至可以在不同的云账户中运行。 租户独立消费资源,因此一个租户的使用不会影响其他租户,从而消除了“吵闹邻居”问题。 然而,大多数公司不会选择这条路,原因有几点: 运营开销:将应用程序部署到数百或数千个生产环境,每个环境都有自己的数据库和配置,管理起来可能非常复杂。 成本:如果您的公司需要为每个租户的资源支付云服务提供商的费用,成本可能会迅速变得难以承受。 可扩展性:如果添加新租户需要部署新实例,那么扩展应用程序以支持许多租户可能会成为瓶颈。 可见性:跨多个实例监控和调试问题可能具有挑战性,因为您需要从所有实例中聚合日志和指标。 逻辑隔离 另一种方法是运行单个应用程序实例来服务多个租户,通常称为“逻辑隔离”。在此模型中,租户共享相同的应用程序代码和数据库,但它们的数据在逻辑上是隔离的。逻辑隔离可以总结为: 共享基础设施,作用域请求 让我们看看在 Go 应用程序中实际如何使用这个示例,从一个简单的 GORM 示例开始: package main type Tenant struct { ID uint `gorm:"primaryKey" json:"id"` Name string `json:"name"` } type Customer struct { ID uint `gorm:"primaryKey" json:"id"` Name string `json:"name"` TenantID uint `json:"tenant_id"` } 在这个示例中,我们有两个模型: Tenant 和 Customer 。每个 Customer 属于一个 Tenant , Customer 模型中的 TenantID 字段用于将每个客户与特定的租户关联起来。 ...

2025-05-30 · 6 分钟 · czyt

Sherpa Go语言实战

简介 sherpa 是 Next-gen Kaldi 项目的部署框架。 使用 VAD 语音活动检测(Voice Activity Detection,简称VAD)是一种技术,用于检测音频信号中是否存在语音或其他声音活动。它在语音处理、语音识别、音频压缩等领域有广泛的应用。 VAD的主要功能 语音识别系统:通过VAD,系统可以在检测到语音时启动识别过程,提高效率。 音频压缩:在语音通信中,VAD可以帮助压缩算法仅对有效语音信号进行压缩,减少传输数据量。 噪声抑制系统:通过检测语音活动,系统可以在静默时段增强噪声抑制效果。 在GO中使用 todo KWS 关键词唤醒(Keyword Spotting,简称KWS)是一种技术,用于检测音频信号中特定的关键词或短语。它广泛应用于语音助手、智能家居设备、车载系统等领域,通过识别特定关键词来激活设备或执行特定命令。 主要功能 关键词检测:识别音频信号中是否包含预定义的关键词或短语。 唤醒设备:当检测到关键词时,激活设备或应用程序。 提高用户体验:通过语音命令简化操作流程,增强用户体验。 自定义keywords 通过官方的工具sherpa-onnx-cli,可以实现自定义关键字,下面是简单的介绍 原文 # Note: You need to run pip install sherpa-onnx to get the commandline tool: sherpa-onnx-cli sherpa-onnx-cli text2token --help Usage: sherpa-onnx-cli text2token [OPTIONS] INPUT OUTPUT Options: --text TEXT Path to the input texts. Each line in the texts contains the original phrase, it might also contain some extra items, for example, the boosting score (startting with :), the triggering threshold (startting with #, only used in keyword spotting task) and the original phrase (startting with @). Note: extra items will be kept in the output. example input 1 (tokens_type = ppinyin): 小爱同学 :2.0 #0.6 @小爱同学 你好问问 :3.5 @你好问问 小艺小艺 #0.6 @小艺小艺 example output 1: x iǎo ài tóng x ué :2.0 #0.6 @小爱同学 n ǐ h ǎo w èn w èn :3.5 @你好问问 x iǎo y ì x iǎo y ì #0.6 @小艺小艺 example input 2 (tokens_type = bpe): HELLO WORLD :1.5 #0.4 HI GOOGLE :2.0 #0.8 HEY SIRI #0.35 example output 2: ▁HE LL O ▁WORLD :1.5 #0.4 ▁HI ▁GO O G LE :2.0 #0.8 ▁HE Y ▁S I RI #0.35 --tokens TEXT The path to tokens.txt. --tokens-type TEXT The type of modeling units, should be cjkchar, bpe, cjkchar+bpe, fpinyin or ppinyin. fpinyin means full pinyin, each cjkchar has a pinyin(with tone). ppinyin means partial pinyin, it splits pinyin into initial and final, --bpe-model TEXT The path to bpe.model. Only required when tokens-type is bpe or cjkchar+bpe. --help Show this message and exit. 我这里只是记录下小爱同学 :2.0 #0.6 @小爱同学 这部分。在这个例子里面:数字 是增强得分(boosting score),#数字 是触发阈值(triggering threshold)。 ...

2025-03-20 · 6 分钟 · czyt

在Go程序中使用Vosk进行语音识别STT

Vosk 介绍 Vosk是一款基于深度学习 的开源语音识别工具,能够在没有云连接的情况下进行高效的离线语音识别。它通过对语音信号进行预处理、特征提取和模型推断,将语音转换成文本。Vosk不仅支持多种主流编程语言,还覆盖了20多种语言和方言,包括英语、中文、法语、德语等,为跨语言应用提供了强大的支持。 工作原理 Vosk的语音识别过程可以分为以下几个关键步骤: 语音信号预处理:对输入的语音信号进行去噪、增强等处理,以提高识别准确性。 特征提取:从处理后的语音信号中提取出能够表征语音特性的关键特征。 模型推断:利用预训练的深度学习模型对提取的特征进行识别,输出对应的文本。 优势解析 隐私保护:Vosk的离线特性意味着用户的语音数据不会离开设备,有效保护了用户的隐私。 实时性:在设备端进行语音识别,减少了网络传输时间和延迟,使得识别过程更加实时。 跨平台:支持Windows、Linux、macOS以及嵌入式设备等多种平台,便于在不同场景下的应用。 可扩展性:作为开源项目,Vosk允许开发者根据自己的需求进行定制和优化,以适应不同的应用场景。 多语言支持:提供对多种语言和方言的识别能力,为跨国应用提供了便利。 官方 https://alphacephei.com/vosk/ 模型下载 https://alphacephei.com/vosk/models GO项目搭建 本文基于Linux,Windows请参考官方仓库的说明 创建go程序 创建go程序内容如下: package main import ( "encoding/json" "flag" "fmt" "io" "log" "os" vosk "github.com/alphacep/vosk-api/go" ) func main() { var filename string flag.StringVar(&filename, "f", "", "file to transcribe") flag.Parse() model, err := vosk.NewModel("model") if err != nil { log.Fatal(err) } // we can check if word is in the vocabulary // fmt.Println(model.FindWord("air")) sampleRate := 16000.0 rec, err := vosk.NewRecognizer(model, sampleRate) if err != nil { log.Fatal(err) } rec.SetWords(1) file, err := os.Open(filename) if err != nil { panic(err) } defer file.Close() buf := make([]byte, 4096) for { _, err := file.Read(buf) if err != nil { if err != io.EOF { log.Fatal(err) } break } if rec.AcceptWaveform(buf) != 0 { fmt.Println(rec.Result()) } } // Unmarshal example for final result var jres map[string]interface{} json.Unmarshal([]byte(rec.FinalResult()), &jres) fmt.Println(jres["text"]) } 下载模型 同时需要下载对应的模型,我这里使用中文的模型 https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip ...

2025-03-17 · 2 分钟 · czyt

Go程序自动升级的方案探究

前导 2024年的最后一天看见v站的这个 帖子,故整理下,备忘。 常用的库 cloudflare tableflip 仓库为 https://github.com/cloudflare/tableflip 官方示例 package tableflip_test import ( "context" "flag" "fmt" "log" "net/http" "os" "os/signal" "syscall" "time" "github.com/cloudflare/tableflip" ) // This shows how to use the upgrader // with the graceful shutdown facilities of net/http. func Example_httpShutdown() { var ( listenAddr = flag.String("listen", "localhost:8080", "`Address` to listen on") pidFile = flag.String("pid-file", "", "`Path` to pid file") ) flag.Parse() log.SetPrefix(fmt.Sprintf("%d ", os.Getpid())) upg, err := tableflip.New(tableflip.Options{ PIDFile: *pidFile, }) if err != nil { panic(err) } defer upg.Stop() // Do an upgrade on SIGHUP go func() { sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGHUP) for range sig { err := upg.Upgrade() if err != nil { log.Println("Upgrade failed:", err) } } }() // Listen must be called before Ready ln, err := upg.Listen("tcp", *listenAddr) if err != nil { log.Fatalln("Can't listen:", err) } server := http.Server{ // Set timeouts, etc. } go func() { err := server.Serve(ln) if err != http.ErrServerClosed { log.Println("HTTP server:", err) } }() log.Printf("ready") if err := upg.Ready(); err != nil { panic(err) } <-upg.Exit() // Make sure to set a deadline on exiting the process // after upg.Exit() is closed. No new upgrades can be // performed if the parent doesn't exit. time.AfterFunc(30*time.Second, func() { log.Println("Graceful shutdown timed out") os.Exit(1) }) // Wait for connections to drain. server.Shutdown(context.Background()) } 这个示例实现了以下功能: ...

2024-12-31 · 2 分钟 · czyt

对称和反向gRPC连接【译】

原文链接为 https://tilde.town/~hut8/post/grpc-connections/ 使用kimi进行翻译 背景 gRPC是一项出色的现代技术,用于远程过程调用。它允许你在客户端创建一个“存根”对象,该对象的目的是调用服务器上的方法。它是许多情况下REST或GraphQL的绝佳替代品,通常值得学习这项技术。更多信息可以在官方文档中找到。 网络 概念上,我们对客户端和服务器有两种不同的想法。 TCP服务器和客户端 - gRPC在HTTP/2之上运行,HTTP/2在TCP之上运行,所以我将讨论TCP中“服务器”和“客户端”的含义。在TCP连接中,客户端是连接的发起者,服务器是连接的接收者。然而,一旦建立了连接,连接就是对称的;客户端和服务器都可以发送和接收消息,直到一方通过shutdown(2)关闭,或通过close(2)关闭连接。 gRPC服务器和客户端 - gRPC客户端通过存根调用在服务器上运行的方法。这不是对称的。服务器不能在客户端上调用方法。 服务器/客户端类型耦合问题 如果你有一个gRPC客户端,它也是TCP客户端(它调用connect(2))。如果你有一个gRPC服务器,它也是TCP服务器(它调用listen(2)和accept(2))。 因此,如果你想让两台机器可以相互调用gRPC,那么每台机器都是客户端和服务器(在TCP和gRPC两种意义上),现在你有了两个与彼此无关的TCP连接。在有两朵云实例的场景中,这种架构通常并不复杂。但TCP通常很混乱。防火墙、NAT和动态分配的IP地址可能会使客户端到服务器的单向连接变得复杂。 这里有一个这样的情况的例子:假设你的“服务器”是一个在家庭路由器后面的笔记本电脑上运行的程序,它必须接收命令(例如,远程控制)来自“客户端”(例如,云计算实例)。根据gRPC,客户端必须是云计算实例(这很容易被称为“服务器”),因为那是创建远程过程调用的一方。服务器是笔记本电脑,因为那是实际发生过程调用的地方。 当你在客户端(即云计算实例)上创建一个“存根”时,你必须创建一个TCP连接并连接到服务器(即笔记本电脑)。现在你遇到了几个可能的问题: 你不知道笔记本电脑的IP地址,所以你现在需要一个反向服务,让笔记本电脑告诉云它的IP地址。 笔记本电脑可能在NAT后面,所以一旦你找到了IP地址,笔记本电脑将不得不配置端口转发。这在企业环境中很可能是不可能的。 笔记本电脑可能在防火墙后面,这将禁止传入连接。这可能是本地机器上的,也可能是在网关路由器上的。 这些问题中的一些可能是无法解决的,所以我们需要另一种方法。 gRPC独有的解决方案 服务器不能在客户端gRPC上调用方法。也许你可以让客户端在服务器上调用一个方法,其唯一目的是接收描述服务器希望客户端运行的方法的消息。这可以通过流式响应来完成。但这很复杂,需要大量的代码来绕过gRPC的设计。我在其他地方看到过这个建议,但我认为这是一个丑陋的临时解决方案,它制造的问题比它解决的还要多。考虑一下,你将如何在静态类型的方式中实际调用这些“客户端方法”。 基于隧道的解决方案 将TCP客户端/服务器从gRPC客户端/服务器中解耦的一个好方法是实现某种隧道。在上面的例子中,笔记本电脑可以向云计算实例发起一个TCP连接,然后通过实现特定于语言的接口,“拨号”操作让云计算实例(gRPC客户端)连接到笔记本电脑(gRPC服务器)可以简单地使用现有的TCP连接。 SSH是一个不可思议的协议,它的用途比大多数用户知道的还要多。在我们的情况下,它是将我们的TCP连接从gRPC连接中解耦的完美方式。它还有其他好处:尽管gRPC提供认证和加密,但如果更方便,你可以使用SSH提供的。 这些例子是Go语言特有的,但你可以在任何语言中做类似的事情。gRPC服务器不需要监听端口;你可以传入任何实现了Go的net.Listener的类型。所以我们可以做一个net.Listener,它将接受SSH连接,任何时候请求我们的自定义类型的新SSH通道,我们将接受它并返回一个新的net.Conn,这是我们将实现的另一个类型,它只是通过我们的隧道传输数据。 让我们从SSHDataTunnel开始,它是我们的net.Conn。 import ( "net" "time" "golang.org/x/crypto/ssh" ) // SSHDataTunnel实现了net.Conn type SSHDataTunnel struct { Chan ssh.Channel Conn net.Conn } func NewSSHDataTunnel(sshChan ssh.Channel, carrier net.Conn) *SSHDataTunnel { return &SSHDataTunnel{ Chan: sshChan, Conn: carrier, } } func (c *SSHDataTunnel) Read(b []byte) (n int, err error) { return c.Chan.Read(b) } func (c *SSHDataTunnel) Write(b []byte) (n int, err error) { return c.Chan.Write(b) } func (c *SSHDataTunnel) Close() error { return c.Chan.Close() } func (c *SSHDataTunnel) LocalAddr() net.Addr { return c.Conn.LocalAddr() } func (c *SSHDataTunnel) RemoteAddr() net.Addr { return c.Conn.RemoteAddr() } func (c *SSHDataTunnel) SetDeadline(t time.Time) error { return c.Conn.SetDeadline(t) } func (c *SSHDataTunnel) SetReadDeadline(t time.Time) error { return c.Conn.SetReadDeadline(t) } func (c *SSHDataTunnel) SetWriteDeadline(t time.Time) error { return c.Conn.SetWriteDeadline(t) } // 静态检查类型 var _ net.Conn = &SSHDataTunnel{} 最后一行只是确保我们的类型实际上实现了net.Conn,如果它没有,它将无法编译。 ...

2024-12-10 · 3 分钟 · czyt

在Go语言中使用Arrow、Flight和Duckdb

从duckdb开始 DuckDB 是一个嵌入式分析型数据库,专为 OLAP(在线分析处理)工作负载设计。本文将基于 go-duckdb 项目的示例,详细介绍 DuckDB 在 Go 语言中的各种使用场景。 基础使用 简单查询 package main import ( "database/sql" _ "github.com/marcboeker/go-duckdb" ) func main() { db, err := sql.Open("duckdb", ":memory:") if err != nil { panic(err) } defer db.Close() // 执行查询 rows, err := db.Query("SELECT 42") if err != nil { panic(err) } defer rows.Close() } 高级特性 Copy COPY 函数可以用于导入导出数据: ...

2024-12-05 · 6 分钟 · czyt

golang检测字节是否为utf-8字节的起始字节

原理 UTF-8 编码使用不同长度的字节序列来表示 Unicode 字符。这些序列的长度从一个字节到四个字节不等,每个字节都有特定的比特模式。您可以通过观察任何字节的最高位(也就是最左边的几位比特),来判断这个字节是不是某个字符的 UTF-8 编码的起始字节或中间字节: 单字节字符(U+0000 到 U+007F)以 0xxxxxxx 为模式,其中 x 可以是 0 或 1。这种单字节的高位为 0,表示它是 ASCII 字符的起始(也是唯一)字节。 UTF-8 编码的起始字节(即多字节序列的第一个字节)模式为 110xxxxx(对于两字节编码)、1110xxxx(对于三字节编码)或 11110xxx(对于四字节编码)。 对于一个 UTF-8 字符的非起始字节(也称为连续字节或中间字节),其模式为 10xxxxxx。 因此,如果你看到一个字节,它的最高两位是 10,那么它是 UTF-8 编码中的一个连续字节。如果最高一位是 0 或者高位模式匹配 110、1110 或 11110,它就是起始字节。 举例来说: 0xxxxxxx - UTF-8 字符的起始字节(单字节 ASCII 字符) 110xxxxx - 两字节编码的起始字节 1110xxxx - 三字节编码的起始字节 11110xxx - 四字节编码的起始字节 10xxxxxx - 中间字节 通过检查字节的最高位,您就可以迅速确定它是 UTF-8 编码序列的起始字节还是中间字节。 实现 go语言实现 package main import ( "fmt" ) // isUTF8StartByte checks whether a byte is a UTF-8 start byte. func isUTF8StartByte(b byte) bool { // For single-byte (ASCII), starts with 0xxxxxxx if b&0x80 == 0x00 { return true } // For multibyte sequences, if a byte starts with // 110xxxxx, 1110xxxx, or 11110xxx, it is a start byte. if (b&0xE0 == 0xC0) || (b&0xF0 == 0xE0) || (b&0xF8 == 0xF0) { return true } // Otherwise, it is a continuation byte (10xxxxxx). return false } func main() { examples := []byte{0b01110100, 0b11011110, 0b11101110, 0b10111100, 0b00111100} for _, b := range examples { if isUTF8StartByte(b) { fmt.Printf("Byte 0x%X is a start byte of a UTF-8 character.\n", b) } else { fmt.Printf("Byte 0x%X is a continuation or invalid byte of a UTF-8 character.\n", b) } } }

2024-02-19 · 1 分钟 · czyt

go switch的6种用法【译】

文章原文链接为 https://blog.devtrovert.com/p/switch-in-go-6-ways-to-use-it 照片由 Zbyněk Skrčený 在 Unsplash 上拍摄 Go 以其简单而闻名,但我注意到并不是每个人都熟悉 switch 语句在这种语言中的多功能性。 首先,如果您不熟悉 Go 的 switch 语句,与其他语言相比,它可能看起来有点不同。这是一个简单的例子来展示它的样子: func main() { var i int = 1 switch i { case 1: fmt.Println("i is 1") case 2: fmt.Println("i is 2") default: fmt.Println("i is not 1 or 2") } } Go 的 switch 的一个很酷的事情是,一旦找到匹配项,它就会停止,你不需要在每个 case 的末尾添加一个 break 语句。 但不仅仅如此。 Go 中的 switch 语句有两部分:分号之前的部分是初始化器,分号之后的部分是我们要检查的值。 ...

2024-02-17 · 3 分钟 · czyt

使用结构或可变参数选项简化go函数签名【译】

文章内容来自func25的 twitter 在Go中设计函数时,我们可能会遇到需要传递大量参数的情况。 这可能会影响函数的目的,并使维护代码成为一件苦差事,特别是当涉及相同类型的参数时。 为了保持整洁,请考虑两种策略: 选项结构。 可变选项。 选项结构体 将参数捆绑到一个结构中,这不仅增强了可读性,还简化了参数传递。 什么时候使用它? 你的函数有一个很长的参数列表。 您的目标是自记录代码,因为结构字段本质上描述了它们的用途(通过名称)。 您希望轻松设置默认值或灵活修改选项。 使用此模式时, context.Context 应保留为单独的参数,而不应包含在选项结构中。 这是由于上下文在控制请求范围值、截止日期和取消信号方面的独特作用。 但在使用时有一些小技巧: 该结构应该向后兼容,以便在添加新字段时,我们不会破坏之前的任何内容。 我们始终可以在处理结构之前对其进行验证。 考虑隐藏您的选项结构(使其不导出),然后公开 NewXXX() 函数并在那里设置默认值。 可变参数选项 此方法利用 Go 的函数功能,允许您以更简洁的方式传递灵活数量的选项。 它在以下情况下是理想的: 该功能需要高度可配置。 大多数选项是可选的或很少使用。 您更喜欢简洁的调用站点。 设置默认值比使用可选结构更容易,您不需要隐藏它,只需将默认值直接放在 ConnectToDatabase 中即可。

2024-02-14 · 1 分钟 · czyt

关于七牛qetag算法的一些记录

背景 今天做七牛相关接口开发的时候发现七牛的文件查询列表返回,有个hash字段,但是不知道是怎么进行计算的。后来查询到七牛的官方仓库 qetag .官方对这个算法的描述是这样的: qetag 是一个计算文件在七牛云存储上的 hash 值(也是文件下载时的 etag 值)的实用程序。 七牛的 hash/etag 算法是公开的。算法大体如下: 如果你能够确认文件 <= 4M,那么 hash = UrlsafeBase64([0x16, sha1(FileContent)])。也就是,文件的内容的sha1值(20个字节),前面加一个byte(值为0x16),构成 21 字节的二进制数据,然后对这 21 字节的数据做 urlsafe 的 base64 编码。 如果文件 > 4M,则 hash = UrlsafeBase64([0x96, sha1([sha1(Block1), sha1(Block2), …])]),其中 Block 是把文件内容切分为 4M 为单位的一个个块,也就是 BlockI = FileContent[I*4M:(I+1)*4M]。 为何需要公开 hash/etag 算法?这个和 “消重” 问题有关,详细见: https://developer.qiniu.com/kodo/kb/1365/how-to-avoid-the-users-to-upload-files-with-the-same-key http://segmentfault.com/q/1010000000315810 为何在 sha1 值前面加一个byte的标记位(0x16或0x96)? 0x16 = 22,而 2^22 = 4M。所以前面的 0x16 其实是文件按 4M 分块的意思。 0x96 = 0x80 | 0x16。其中的 0x80 表示这个文件是大文件(有多个分块),hash 值也经过了2重的 sha1 计算。 语言封装 C# 实现 基于官方仓库的csharp代码做了部分修改 ...

2024-01-22 · 3 分钟 · czyt