最近一年,我用Rust做了几个高并发的后端服务。

从最开始的不习惯,到后来慢慢体会到Rust的优势,这个过程走了不少弯路。Rust 1.75版本之后,异步编程和类型系统都有了不少改进,写高并发服务比以前顺手多了。

这篇文章,我想分享一下用Rust构建高可用高并发系统的一些架构设计经验。

为什么选择Rust

先说说为什么在高并发场景下选择Rust。

我们团队之前主要用Go和Java。Go的并发模型很优秀,开发效率高,但在某些场景下,GC的停顿会影响延迟。Java的生态成熟,但内存占用大,启动慢,而且JVM调优是个无底洞。

Rust的优势在于:零成本抽象、没有GC、内存安全、高性能。这些特性让Rust非常适合做高并发、低延迟的系统。

我们做的一个网关服务,用Rust重写之后,吞吐量提升了3倍,P99延迟降低了60%,内存占用只有原来Java版本的十分之一。这个结果让我们团队决定,后续的高性能服务都优先用Rust开发。

当然,Rust也有缺点。学习曲线陡峭,开发效率比Go低,生态还不够成熟。但对于性能要求高的场景,这些代价是值得的。

异步运行时的选择

Rust的异步编程是基于Future的,标准库只提供了Future trait,具体的运行时需要第三方库。

目前最主流的运行时是tokio,其次是async-std。我们选择了tokio,因为它的生态最成熟,性能也最好。

tokio的多线程运行时,默认会创建和CPU核心数相同的worker线程。每个线程都有自己的任务队列,同时也会做工作窃取(work stealing),保证负载均衡。

在高并发场景下,有几个配置需要注意。

第一个是worker线程数。默认是CPU核心数,但如果你的服务有很多IO等待,可以适当增加线程数。比如,我们的网关服务有大量的网络IO,就把worker线程数设为CPU核心数的2倍。

第二个是任务的调度粒度。tokio的任务调度是协作式的,每个任务在遇到.await的时候会让出线程。如果一个任务里有大量的CPU密集型计算,会长时间占用线程,导致其他任务饿死。

解决方法是,把CPU密集型的计算放到spawnblocking里,让它在专门的阻塞线程池里执行。或者,用tokio::task::yieldnow主动让出线程。

第三个是背压(backpressure)。高并发系统必须有背压机制,否则当请求量超过处理能力时,系统会被压垮。tokio提供了Semaphore和mpsc channel,可以用来实现背压。

比如,我们用Semaphore来限制同时处理的请求数。当信号量满了之后,新的请求会等待,或者直接返回503。这样可以保证系统在高负载下依然稳定。

错误处理

Rust的错误处理是Result类型,这和很多语言不一样。刚开始用的时候,会觉得到处都是?操作符,代码里全是错误处理。

但用习惯了之后,你会发现这种显式的错误处理其实很好。它强迫你思考每个可能出错的地方,而不是像异常那样,错误可能在任何地方抛出,你根本不知道哪里会出问题。

在高并发系统里,错误处理尤其重要。因为并发场景下,错误的种类更多,而且错误的影响范围可能更大。

我们的错误处理策略是这样的:

第一层,定义统一的错误类型。用thiserror库,把所有可能的错误都定义成一个enum。这样,函数的返回类型统一是Result<T, AppError>,处理起来很方便。

第二层,在错误发生的地方记录日志。用tracing库,记录错误的详细信息,包括错误类型、发生位置、相关的上下文。这样出了问题才能快速排查。

第三层,对可恢复的错误做重试。比如网络超时、数据库连接失败等临时性错误,可以用backoff策略重试。但要注意重试的次数和间隔,避免雪崩。

第四层,对不可恢复的错误,返回合适的HTTP状态码和错误信息。不要把内部错误的细节暴露给用户,但要记录足够的信息供内部排查。

还有一个重要的点是,不要用panic来处理错误。panic会导致线程退出,在多线程环境下可能引发更严重的问题。除了初始化阶段的不可恢复错误,其他地方都应该用Result。

状态管理

高并发系统里,状态管理是一个难点。

Rust的所有权系统,让共享状态变得比其他语言更"麻烦"。你不能随便共享一个可变变量,必须用Arc<Mutex<T>>或者Arc<RwLock<T>>。

这种"麻烦"其实是好事,它强迫你思考:这个状态真的需要共享吗?有没有更好的方式?

我们的经验是,尽量避免共享状态。如果必须共享,优先用消息传递的方式,而不是共享内存。

比如,我们有一个配置管理模块。最开始的设计是,所有worker线程共享一个配置对象,用RwLock保护。但后来发现,配置更新的时候,所有线程都要去抢锁,在高并发下会有性能问题。

后来我们改成了消息传递的方式。配置更新的时候,通过channel把新配置发送给每个worker。每个worker维护自己的配置副本,不需要锁。这样虽然多了一些内存开销,但并发性能提升了很多。

如果确实需要共享状态,要注意锁的粒度。尽量用细粒度的锁,而不是一个大锁保护所有数据。比如,用DashMap(一个并发的HashMap库)代替Mutex<HashMap>,性能会好很多。

还有一个技巧是,用原子类型(AtomicUsize、AtomicBool等)来做简单的状态共享,比如计数器、开关等。原子类型比锁轻量得多,在高并发下性能更好。

数据库访问

数据库访问是高并发系统里最容易成为瓶颈的地方。

Rust的数据库驱动,最主流的是sqlx和diesel。sqlx是异步的,编译时检查SQL,用起来很方便。diesel是同步的ORM,类型安全很好,但不支持异步(虽然有异步分支)。

我们选择了sqlx,因为它原生支持异步,和tokio配合得很好。

在高并发场景下,数据库连接池是关键。sqlx自带连接池,默认的连接数是10。但在高并发下,10个连接可能不够,需要根据数据库的处理能力适当调整。

不过,连接数也不是越多越好。太多的连接会导致数据库压力过大,反而降低性能。一般来说,连接数设置为CPU核心数的2-4倍比较合适。

还有一个重要的点是,超时设置。数据库操作必须有超时,否则一个慢查询可能会占用一个连接很长时间,导致连接池耗尽。sqlx可以设置查询超时和连接超时。

我们还做了一个优化:对于读多写少的场景,加了一层缓存。用moka(一个Rust的高性能缓存库)做本地缓存,减少数据库的压力。缓存的更新用消息通知的方式,保证数据的最终一致性。

可观测性

高并发系统必须有良好的可观测性,否则出了问题根本不知道怎么回事。

我们用tracing做日志和追踪。tracing是Rust生态里最主流的可观测性库,支持结构化日志、分布式追踪、指标收集。

在每个请求进来的时候,我们会创建一个span,记录请求的traceid。在整个请求处理过程中,所有的日志都会带上这个traceid。这样,出了问题之后,可以用trace_id把整个请求的日志都搜出来。

指标方面,我们用metrics库,把关键的指标(QPS、延迟、错误率)暴露出来,用Prometheus采集,Grafana展示。设置了告警规则,当指标异常的时候,会自动通知值班人员。

分布式追踪方面,我们用OpenTelemetry。每个服务都会把trace信息传递给下游服务,这样可以在Jaeger里看到一个请求在整个系统里的完整链路。

可观测性是高可用系统的基础。没有良好的可观测性,所谓的高可用只是一句空话。

优雅停机

高可用系统还需要支持优雅停机。也就是,当服务需要重启或者下线的时候,不能直接杀掉进程,而是要先停止接收新请求,处理完正在处理的请求,然后再退出。

tokio提供了很好的优雅停机支持。我们的做法是:

第一步,监听SIGTERM和SIGINT信号。当收到信号的时候,设置一个全局的关闭标志。

第二步,HTTP服务器收到关闭信号后,停止接收新连接,但继续处理已有的连接。

第三步,等待所有正在处理的请求完成。设置一个超时时间(比如30秒),如果超时还有请求没处理完,就强制退出。

第四步,关闭数据库连接池、释放其他资源。

这样做的好处是,发布新版本的时候,不会导致正在处理的请求失败。用户不会感觉到服务中断。

一些经验总结

最后,总结一些用Rust做高并发系统的经验。

第一,先做原型,再优化。不要一开始就追求极致性能,先把功能做对,然后用性能测试找出瓶颈,再有针对性地优化。

第二,善用社区库。Rust的生态虽然不如Java成熟,但很多核心库的质量很高。tokio、sqlx、tracing、serde这些库,都经过了大量生产环境的验证,直接用就好,不要自己造轮子。

第三,注意编译时间。Rust的编译时间比较长,项目大了之后,每次编译可能要几分钟。可以用sccache来做编译缓存,用cargo check代替cargo build来做快速检查。

第四,写测试。Rust的类型系统已经能帮你避免很多错误,但测试还是必不可少的。尤其是并发代码,很多问题只有在高并发下才会出现,需要用压力测试来发现。

第五,持续学习。Rust发展很快,每个新版本都会带来新的特性和改进。保持学习,跟上社区的最佳实践,才能写出更好的Rust代码。

Rust不是银弹,它不会 magically 让你的系统变高可用高并发。但它提供了很好的基础,让你在构建高性能系统的时候,有更多的保障。只要架构设计合理,细节处理到位,Rust完全可以胜任最严苛的高并发场景。