转载 | 不可变数据共享
在构建高性能 Go
应用程序时,一个常见的瓶颈是对共享数据的并发访问。传统的方法通常涉及使用互斥锁或通道来管理同步。虽然这些工具有效,但如果使用不当,可能会增加复杂性并引入隐蔽的错误。
一种强大的替代方案是不可变数据共享。通过不使用锁来保护数据,您可以设计系统,使得共享数据在创建后永远不会被更改。这最小化了争用并简化了对程序的推理。
为什么选择不可变数据?
不可变性为并发程序带来了几个优势:
- 无需锁:多个协程可以安全地读取不可变数据而无需同步。
- 更容易推理:如果数据不能改变,您可以避免整类竞态条件。
- 写时复制优化:您可以创建结构的新版本而不改变原始结构,这对于配置重新加载或版本控制状态非常有用。
实际示例:共享配置
假设你有一个长期运行的服务,它会定期从磁盘或远程源重新加载配置。多个 goroutine
会读取此配置以进行决策。
步骤1:定义 Config 结构体
// config.go
type Config struct
步骤2:确保深度不可变性
Go
中的 map
和 slice
是引用类型。即使 Config
结构体本身没有被修改,也可能有人不小心修改了共享的 map
。为防止这种情况,我们制作防御性拷贝:
func NewConfig(logLevel string, timeout time.Duration, features map[string]bool) *Config
现在,每个配置实例都是自包含的,且可以安全共享。
步骤3:原子交换
使用 atomic.Value
来存储并安全地更新当前配置。
var currentConfig atomic.Pointer[Config]
func LoadInitialConfig()
func GetConfig() *Config
现在所有 goroutine
都可以安全地调用 GetConfig()
,无需加锁。当配置重新加载时,只需存储(Store
)一个新的不可变副本。
步骤4:在处理函数中使用
func handler(w http.ResponseWriter, r *http.Request)
实际示例:不可变路由表
假设你正在构建一个轻量级的反向代理或 API
网关,必须根据路径或主机来路由传入的请求。路由表每秒被读取成千上万次,但只偶尔更新(例如,从配置文件或服务发现中)。
步骤1:定义路由结构体
type Route struct
type RoutingTable struct
步骤2:构建不可变版本
为了确保不可变性,在构造新的路由表时,我们对路由切片进行深拷贝。
func NewRoutingTable(routes []Route) *RoutingTable
步骤3:原子存储
var currentRoutes atomic.Pointer[RoutingTable]
func LoadInitialRoutes()
func GetRoutingTable() *RoutingTable
步骤4:并发路由请求
func routeRequest(path string) string
这样,路由逻辑可以在负载下安全扩展,且无需加锁开销。
扩展不可变路由表
随着系统的发展,路由表可能包含数百甚至数千条规则。每次微小的更改都重建和复制整个结构,可能变得不切实际。
下面介绍几种在保持不可变性优点的同时,演进该设计的方法。
场景1:分段路由
假设有一个多租户系统,每个客户都有自己的一套路由规则。与其使用一大段路由,不如将它们拆分成一个映射:
type MultiTable struct
如果只有客户 "acme" 更新他们的规则,你只需复制那一段并更新映射。然后你原子交换整个映射的新版本。其他租户继续使用它们现有的、未被修改的路由表。
这种方法降低了内存压力,加快了更新速度,同时不失去不可变性的优势。它还实现了影响范围隔离:一个段中的错误规则不会影响其他段。
场景2:索引路由表
假设你的路由器通过精确路径匹配,且查找速度至关重要。你可以使用 map[string]RouteHandler
作为索引:
type RouteIndex map[string]RouteHandler
当添加新路径时,克隆当前的映射,添加新路由,并发布新版本。由于映射是浅拷贝,对于中等数量的路由,这种方式是快速的。读取是常数时间,更新也高效,因为只有结构的一小部分发生变化。
场景3:混合分阶段更新和发布
假设你正在进行批量更新——比如从数据库读取数百条路由。你可以保留一个可变的暂存区,而不是直接在线重建:
var mu sync.Mutex
var stagingRoutes []Route
可以在暂存中以互斥方式加载和操作数据,然后转换为不可变 RoutingTable
并以原子方式存储。这样,就可以安全地准备复杂的更改,而不会锁定读取器或影响实时流量。
基准测试影响
在现实系统中,对不可变数据共享进行基准测试很难以通用且有意义的方式进行。诸如结构大小、读写比例和内存布局等因素都会对结果产生重大影响。
与其在这里展示人为的基准测试,推荐查看原子操作与同步原语一文中的测试结果。那些基准测试清楚地展示了使用 atomic.Value
相较于传统的同步原语(如 sync.RWMutex
)在性能上的潜在优势,特别是在高度并发的读场景中。
何时使用此模式
不可变数据共享的理想场景包括:
-
数据以读操作为主、写操作较少(例如配置、功能标志、全局映射)。这种情况下,创建不可变新版本的成本可以通过大量读取来摊销,避免锁机制还能提升性能。
-
希望在不牺牲安全性的前提下尽量减少锁的使用。通过共享只读数据,可以消除对互斥锁或协调机制的需求,降低死锁或竞态条件的风险。
-
可以容忍更新和读取之间存在轻微延迟(最终一致性)。由于数据更新与读取者之间没有协调,所有协程看到新版数据前可能会有短暂延迟。如果对精确时序要求不高,这种权衡能简化并发模型。
它不太适合需要跨多条数据进行事务性更新或频繁发生更新的场景。在这些情况下,重复复制的成本或协调缺失可能会抵消其带来的好处。
- https://goperf.dev/01-common-patterns/immutable-data