自己做app,后端选Node.js还是Go?

1 min read

我的纠结

我在做自己的app的时候,纠结于服务器端的技术选型。

我的app需要用户的多设备同步内容。这是个小说app,谁会同时开着俩设备来回倒腾呢?我觉得没必要做得太极致,定期push pull内容就行了吧?

自己测试了一些使用场景。发现问题挺大的。

这么push pull,要么直接 Last Write Wins,要么把冲突的内容摊开,让用户自己选咋处理。

怎么想都挺麻烦的。不是很好的使用体验。需要一个无缝的同步方法。

于是决定去找个CRDT的实现。 CRDT 算法能自动处理冲突,像魔法一样。具体可参考这个论文

最常用的 CRDT 库是 Yjs。Yjs的 diff 非常快,但是为了 diff,得在server端存所有的blob。对于一个小说app,blob不会特别大,但假如用户高频保存,blob 从硬盘到内存的这个过程依然很不划算。

我的另一个核心诉求是给服务器端减少压力,从而省钱。

yjs最常用的同步机制 provider 是基于 websocket 的。我这个小说 app 不需要实时协作,只需要把同步做好即可。于是决定舍弃 websocket,回归定期 push pull。但是 server 端存 blob 依然是个问题。

于是我决定 server 端只存更新的logs。每次用户 push ,直接 insert 即可。如果 pull ,根据本地的 log index ,把 server 的 index 之后的 logs 发过去,客户端自己应用就好了。

然而,如果是新设备一次全量pull,客户端压力可能会很大。但定期 snapshot 做 compaction 能解决。这么看,server 端已经成了一个简单的类似消息队列的东西,不需要建立一堆 websocket 链接,也不作为用户数据真源,很轻松。这正是我想要的 local-first 的体验。作家群体缺少安全感,这也是我把 app 从 web 端改为 electron 的原因。

之前简陋的 web 端实现中,我用的 node.js 。现在架构大改,后端也得重新做了。那么到底应该选什么语言,什么框架呢?我需要越省钱越好,支持的用户数越高越好。

为什么考虑node

我的 app 之前的 MVP 用的 node.js 后端。没什么别的原因,只是因为它对我的需求足够。

先快速回顾下node.js的事件循环模型。node.js的事件循环遵循 Reactor 模式,什么是 Reactor 模式?可以理解为一个线程负责快速接任务,然后把任务“打出去”,让 blocking 的过程对于 client 不可见。这样,并发就能轻松打得很高。对于node.js来说,高消耗的操作,比如网络请求,让操作系统kernel在背后负责即可;对于磁盘读写之类的底层接口,libuv也会自动分配thread来做。

举例来说,js发了一堆网络请求,node.js会通过libuv,把socket注册到系统的epoll之类的多路复用的监控列表里面,然后OS内核监听socket,来了之后,才会之前socket上注册的回调放进宏任务队列,让v8引擎在下一轮执行。等网卡socket来消息的这个过程,主线程可以做别的事。除了libuv事件循环中的宏任务队列外,node.js的v8引擎还有自己的俩微任务队列(包括process.nextTick和promise的),插在宏任务队列之间做。

注意,如果微任务里如果有密集的计算任务,会block主线程。对于宏任务,虽然I/O、加密、压缩之类的的libuv内置的底层异步模块能分派给thread pool做,但是纯js计算不行,如果不手动用worker thread,依然会block node的主线程。

只要别让主线程干重活,大多数时间,对于开发前中期的app,node.js是够用的。我的app就是这样,所以我一开始用的node.js。如果按照新的server架构来算,假设有100万用户,每天高峰时段25万人同时写,同时建立25万keep-alive的长连接,因为定期pull push,每个长连接都可以认为比较空闲。那么一个链接占多少内存?内核的socket加上缓冲区加起来按10KB算,加上v8和libuv的用户态对象overhead,似乎最多也就20KB,即使代码写的差,闭包让一些对象无法回收,25万个链接一共也就8GB,完全可以接受。

当然,连接数很多,如果请求频繁,pg肯定是先挂的。这一点可以在client侧batching logs或者做compaction来解决。毕竟这是local-first的app。真源总是在本地。

为什么考虑go

和node.js对比,go似乎有些优势。

首先,对于空闲的长连接开销,go理论上比node.js更小,但实际场景中,只要node.js没有闭包抓了太多东西,两者用户态的开销区别不会很大。

但是,go的并发和node不同,用的GMP模式。goroutine - machine thread - processor。本质上是让一些操作系统的thread来管理所有的goroutine。每个processor有一个队列,另外还有一个全局队列。新建一个goroutine之后,默认放在当前thread属于的processor的本地队列里面,如果满了,放全局队列。

一个thread绑定processor之后,会拿它本地队列的goroutine跑;如果本地没有,则去全局队列拿一半;如果还没有,从go会去其他processor的本地队列里拿一半。要是依然没有,才会空闲。另外,如果有的thread即将因为系统调用之类的原因阻塞,它会释放当前的processor,让其他的thread来利用空闲的资源。

有了这些机制,go应该比node.js更能均衡地利用服务器的核心。这带来了很多延迟上的好处。

尤其是一些极限情况。比如说,如果同时积压了很多请求,即使计算开销不是很大不会阻塞主线程,node.js的尾部请求也会延迟飙升;而go则可以均衡利用所有的核心,延迟整体可控。node.js也可以手动用cluster来管理,但是额外v8的开销难以忽视。

最后,garbage collection方面,go比node.js也有优势。node.js对于存活时间短的对象用的复制清楚,很快,但是对于长连接很多的场景,这些连接都晋升为了老生代,node.js使用mark-sweep mark-compact处理老生代对象,所有的对象都在v8的heap上,清除垃圾之后,为了避免内存碎片化或者给晋升的新生代腾位置,v8得移动他们的位置,为了移动位置,v8得stop the world,而过于臃肿的heap会让stop the world卡住主线程。所以我的app使用场景,node.js的gc可能会有问题。如果连接太多,stw很久还可能会影响心跳保活,并且导致反复重连雪崩。虽然这是应用层能够规避的。

go的garbage collection就在这种场景下健壮很多。go的gc不会整理内存,它从一开始就划分好了块,不用STW扫堆导致很长的blocking,只有gc标记开头收尾会有短暂的SWT。另外,go的gc是并发做的,给对象标记黑白灰,删掉纯白的节点,尽管有CPU开销,但是延迟有保障。最后,go的编译器会做逃逸分析,如果有的变量只分配在函数内部,他就不会去heap而是分配在栈上,gc更加轻松了。

总而言之,对于我的app来说,go几乎能提供全方位更优的性能。不论是内存占用,还是用户体验。

最后选择

看起来,go比node.js强很多啊。我的选择似乎没有悬念。

但我选了node.js。

首先,我“百万用户”的假设并不成立。依然在早期开发中,用node.js就够了。用户量真的scale上去了,我再改也不晚。毕竟我的server就像一个傻傻的MQ。

另外,js/ts是很好的胶水语言。我的app除了同步,还有很多需要耗时耗力的工程,node.js前后端一把梭省时省力;用go的话,可能会更麻烦。

最后,因为我懒。

← Back to all blogs