如何设计高性能Node服务?

17 阅读15分钟

2009 年,创始人 Ryan Dahl 在柏林发布了 Node.js 框架,有利的扩展了前端在业务领域中的边界,我们也就可以自行搭建一些后台服务来支撑业务发展,例如使用 Node 搭建 BFF(Backend for Frontend)优化前端性能。

搭建简单的单机服务相对比较容易,但是搭建能够应对高并发,高吞吐,稳定性的大型服务且并不容易,本文也是从宏观维度出发,采用实际案例为背景,以通俗易懂的方式来解答《如何设计高性能Node服务?》这个命题。

本文分为四个章节,希望能对大家带来不一样的感悟。

  • 前言
    • 解析实现高性能Node架构过程中面临的挑战
    • 高并发挑战、高吞吐挑战、一致性挑战
  • 高并发
    • 结合实际案例来分享如何应对高峰值场景的挑战
  • 高吞吐
    • 结合实际案例来分享如何应对海量数据场景的挑战
  • 一致性
    • 在解决高并发、高吞吐场景中如何兼顾应对一致性挑战

前言

下图是一个常规服务的架构,一方面对外提供服务,供 Web、上游服务调用;另一方面也会调用下一些下游服务,同事自身也会通过分布式部署、集群之间协作完成复杂任务。

随着业务服务用户的持续增长,服务器框架就会面临高并发、高吞吐、一致性三个巨大挑战。

  • 高并发挑战
    往往是服务调用面临短时的超高峰值调用,导致服务器无法短时间处理完,导致其他请求丢失,服务不可用。
  • 高吞吐挑战
    业务增长带来更多的处理任务,导致CPU、内存等资源利用率长时间接近极限,导致服务响应时间变长,进而引起用户等待甚至由于超时而断开连接。
  • 一致性挑战
    分布式部署、云服务、容灾等设计下,复杂任务往往是多进程协作完成,保证服务符合一致性(幂等)预期则变得越来越重要。

要解决以上问题,大家可能想到的最简单方式就是大力出奇迹,增加服务器资源配置。这种方式有其局限性,有一些场景横向扩容并不能很好的解决瓶颈,且十分浪费资源。本文则是希望在满足响应速度和正确率的前提下,极致的利用服务器资源,实现高性能服务支持,即搭建高性能服务架构。

如何评价一个服务是否是高性能服务,其潜力是否被挖掘完全呢?业内是有完善的性能指标来进行评价的。

  • TPS/QPS
    每秒事务数(Transactions)或查询数(Queries)

  • RT
    从请求到响应的时间(P99、P95分位值更关键)

  • CPU
    利用率:CPU使用百分比;负载:单位时间内等待CPU的任务数

  • 存储
    数据库存储、文件存储、高性能存储等利用率

高并发

【案例】针对企业微信API封装的中台服务

这是个比较常见的业务场景,即对成熟的平台服务进行二次封装,增加业务自定义的逻辑。结合下图的服务架构,高并发往往是出现在服务自身被短时间高频访问或者自身调用下游服务导致整体系统出现服务不可用。

下游服务高并发限制

首先我们来拆解自身调用下游服务的高并发挑战,本案例里的下游服务是企业微信API。

【背景】

私域运营对组织架构中客户信息进行大量的查询,中台服务接受的查询QPS持续突破微信调用限制。

企业微信API对外部调用具有以下限制:

  • IP 限制:每个 IP 调用不可超过 20000 次/分
  • 信息正确性:手机号错误较多时,触发安全 IP 限制

如果触及上面的限制,企业微信API就会一段时间内拒绝该IP的访问,导致服务不可用。

【设计】

既然对客户信息的访问是高频操作,且其数据更新频率并不高,是否可以以空间换时间将企业微信的组织架构信息同步到服务内的数据库呢?

1、自建组织架构表

定期爬取组织架构信息全量更新,监听人员信息变更事件增量更新;

2、查询缓存优化

Redis List 数据结构过期时间策略 vs LastN策略;

这样牺牲部分客户信息的部分时效性来减少了对下游服务的调用,为模块内其他服务调用释放了巨大空间。

上游服务高并发限制

参考下游服务的高并发限制,我们也需要对自身服务的高并发访问做限制,来保证整体服务的稳定性。

【背景】

客户运营SOP在每个阶段结束后会快速更新客户的标签,上游批量打标签服务会集中调用客户标签接口。

其中打标签需要调用企微API,存在调用限制,无法通过横向扩容来解决瓶颈,最终导致服务器QPS飙高,服务器无法响应,其他服务也不可用。

【设计】

这里设计的核心就是限频,以拒绝部分峰值的访问来保证整体服务的可用。

采用策略有两种,

  • 基于固定时间窗口的令牌桶算法,对于突发请求有一定缓冲能力,不容易丢弃。

  • 基于滑动时间窗口的漏桶算法,流量整形比较好,缺点则是突发请求更容易被丢弃

业务背景中客户标签是运营过程中最重要的资产,需要尽可能不要丢弃,因此我们采用了令牌桶算法策略。

对于丢弃的突发请求可以增加异步处理和消息队列,在服务器闲暇时间取出未执行任务,进行再次执行。

最终异步处理与消息队列来补偿削峰损失获得比较好的流量整形。

高吞吐

【案例】企业微信回调被频繁触发导致CPU持续告警

企业微信提供了回调配置能力,其可以及时推送状态变化(例如通讯录),也可以提供更加丰富的服务行为,例如用户向应用发送消息时,识别关键字,回放不同的消息内容。

本案例同样配置了回调,其中鉴权模块主要符合进行企业微信鉴权和XML内容解析,业务处理模块拿到内容执行进行逻辑处理和数据更新,实际接入之后我们发现 CPU 的利用率很高。

通过对Node.js 火焰图的分析发现是由于回调触发量很高,业务处理逻辑很重且串行处理回调事件,导致CPU执行压力很大。

【设计】

在高并发章节,我们提到了异步处理和消息队列可以缓存服务器无法处理的任务,同样我们可以建立一个缓存层,将高频的回调进行缓冲,然后分发到对应的业务处理集群内。

缓冲层采用分布消息队列即可。

我们对市场上的方案进行了调研。

经过对比,Kafka 比较适合我们高吞吐的流式数据批处理业务场景,最终形成下图的设计架构。

将回调的鉴权和解析XML独立成一个高性能服务器,保证回调能够快速被消化,并在其中接入 Kafka 的生产者,按照解析后的事件内容按照优先级和重要程度投放到不同的分区和topic中。同时在下游建立不同类型的业务逻辑处理服务集群,各自去监听所需内容所在的topic。

该方案具有以下优势:

  • 削峰填谷&高低搭配: Kafka生产者的高吞吐能力,将流量平缓,业务系统自主控制拉取速度。
  • 可扩展性:提供通用的 Kafka 回调和回调转发接入方案,快速方便企业和业务系统的快速接入。

【案例】会话回放之会话上报服务阻塞

会话回放是前端监控领域针对网页用户行为录制的典型产品,以开源产品RRWeb为例,其收集用户行为的方式之采用DOM全量帧+增量事件的方案记录,即使经过压缩其PC页面数据体积也在1M左右,其上传服务必然要考虑高流量压力。

以下图基于RRWeb实现的会话回放工具为例。

随着接入用户逐渐增多,原来的框架发现不足以支撑会话上传所需,会话上传需要调用Minio存储的push接口,耗时固定(文件大小在1~2M),随着请求增多,http 连接池拥堵,导致后续请求被阻塞。

new http.Agent({ 
  maxSockets: 15,       // 每主机最大并发连接数(默认无限)
  maxFreeSockets: 10    // 空闲连接池大小(默认 256)
})

这个案例本质在于请求的流量占用比较大,且处理时长长,无法快速释放请求连接导致的。

【设计】

在原有的技术方案里,上报SDK会构造上报请求,其内容包括页面所有DOM元素的扫描和后续用户行为和页面变化的增量事件,http body的体积在5M以下;服务接受之后建立与 minio client 连接,开始文件上传;上传结束之后,更新数据库表,并返回结构,释放HTTP连接。

首先我们先通过异步处理和消息队列解耦合业务逻辑,尽快释放Http连接。控制器在收到上报请求之后,创建上传任务,并且向 Node EventEmitter 推送事件,并返回结果释放连接。上传服务监听到事件之后开始读取缓存中的文件,执行任务。

那我们还能继续优化吗?我们发现文件上传的带宽很大,且崩溃风险比较大,那我们将上传服务独立出来,并调优服务器的资源配置。

最终方案具有以下优势:

  • 将复杂任务进行解耦合,不同类型任务进行分层和隔离,保证整体系统的高可用性;
  • 可动态调节上传服务的资源数量和类型,提升上传服务的性能上限;

一致性

一致性是个业务视角的名词,是指服务在各种边界情况下,其表现符合预期。其在技术角度下的含义,更加贴近于幂等设计,下面的介绍中我们也会更多的用幂等设计来指代一致性设计。

常见业务场景:

  • 网络不确定性
    在分布式环境中,网络超时、抖动可能导致客户端重试,需要确保重复请求不会产生副作用
  • 业务一致性
    金融交易、订单处理等场景要求相同操作执行一次与多次结果完全一致
  • 系统容错
    服务崩溃后的恢复机制可能重放请求,幂等性可避免数据错乱
  • 消息队列消费
    消息中间件的at-least-once投递特性要求消费者必须实现幂等处理
  • 户端体验
    用户误操作(如多次点击提交)不应导致业务异常

我们为何要重点关注一致性设计呢?

随着业务成长,单体服务在应对高并发、高吞吐、高性能的挑战时,必然要引入多地部署、容灾备份、集群负载均衡等措施,其复杂任务的处理往往会演变成多进程或者多集群协作处理,常规单进程设计会暴露其重复、丢失、乱序等一致性缺陷。

业内针对于幂等设计提出很多成熟的方案。

【幂等设计方案】

下面我们针对这些方案进行基于代码的通俗解释,尽可能让大家方便理解这些专有名称。

  • 唯一标识法
// 使用唯一ID确保操作只执行一次
public boolean processPayment(String requestId, PaymentRequest request) {
    // 先检查是否已处理
    if (idempotencyCache.contains(requestId)) {
        return true; // 已处理,直接返回成功
    }
    
    // 执行业务逻辑
    boolean result = paymentService.process(request);
    
    // 记录已处理的请求
    if (result) {
        idempotencyCache.put(requestId, EXPIRATION_TIME);
    }
    
    return result;
}
  • 乐观锁设计
UPDATE account_balance 
SET balance = balance - 100, version = version + 1 
WHERE account_id = 123 AND version = 5
-- 通过版本号确保只有第一次更新生效
  • 状态机设计
def fulfill_order(order_id):
    order = get_order(order_id)
    if order.status == 'FULFILLED':
        return True  # 已处理,直接返回

    if order.status == 'CREATED':
        # 执行业务逻辑
        result = warehouse.fulfill(order)
        if result:
            order.status = 'FULFILLED'
            order.save()
        return result

    raise InvalidOrderStateError()
  • 去重表设计
CREATE TABLE idempotency_keys (
  key VARCHAR(128) PRIMARY KEY,
  operation_type VARCHAR(64) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  response_body TEXT
);
  • Token机制
  1. 客户端先请求服务端获取一个唯一token
  2. 执行业务请求时携带该token
  3. 服务端校验token有效性后处理请求并标记token已使用
// 生成并存储token
public String generateToken() {
    String token = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set("token:"+token, "valid", 5, TimeUnit.MINUTES);
    return token;
}

// 校验token
public boolean processWithToken(String token, BusinessRequest request) {
    // 原子性校验并删除token
    Long result = redisTemplate.execute(
        new DefaultRedisScript<>(
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "return redis.call('del', KEYS[1]) " +
            "else return 0 end", 
            Long.class),
        Collections.singletonList("token:"+token),
    "valid");

    if (result == 1) {
        // 执行业务逻辑
        return businessService.process(request);
    }
    throw new InvalidTokenException();
  • 自然幂等设计
UPDATE accounts SET balance = balance - 100 WHERE id = 123

-- 幂等设计:设置绝对值
UPDATE accounts SET balance = 900 WHERE id = 123 AND balance = 1000
  • 请求指纹
  1. 对请求关键参数生成唯一指纹(如MD5/SHA1)
  2. 用指纹作为幂等键
def create_request_fingerprint(request):
    import hashlib
    key_parts = [
        str(request.user_id),
        request.order_type,
        str(request.amount),
        request.timestamp
    ]
    return hashlib.sha256(','.join(key_parts).encode()).hexdigest()

def handle_request(request):
    fingerprint = create_request_fingerprint(request)

    with redis.lock(f"lock:{fingerprint}"):
        if redis.get(f"processed:{fingerprint}"):
            return {"status": "already_processed"}

        # 处理业务逻辑
        result = process_business(request)

        redis.setex(f"processed:{fingerprint}", 3600, "1")
        return result
  • 分布式锁控制
func ProcessOrder(orderID string) error {
    // 获取分布式锁
    lockKey := fmt.Sprintf("order:%s:lock", orderID)
    locked, err := redis.SetNX(lockKey, 1, 10*time.Second).Result()
    if err != nil {
        return err
    }
    if !locked {
        return errors.New("operation in progress")
    }
    defer redis.Del(lockKey)

    // 检查是否已处理
    processed, err := checkIfProcessed(orderID)
    if err != nil {
        return err
    }
    if processed {
        return nil
    }

    // 执行业务处理
    return realProcessOrder(orderID)
}
  • 消息队列幂等消费

RabbitMQ实现示例

// 使用消息ID作为幂等键
const processedMessages = new Set();

channel.consume('order_queue', (msg) => {
    if (processedMessages.has(msg.properties.messageId)) {
        channel.ack(msg);
        return;
    }
    
    try {
        processOrder(JSON.parse(msg.content.toString()));
        processedMessages.add(msg.properties.messageId);
        channel.ack(msg);
    } catch (err) {
        channel.nack(msg);
    }
});

【案例】企业微信SOP消息群发任务

在客户运营过程中,为了标准化运维对于客户管理的标准化过程,保证服务质量,一般都会引入SOP - 标准作业程序(Standard Operating Procedure),简称剧本。这些水军按照自动化的剧本在群组内进行演绎,来启动活跃气氛,促进业务转化。

下图是一个翻车现场,其中一个账号重复发了消息导致被客户识别出来,带来巨大的损失。

这样一个按照时间和角色的二维任务表,面临以下挑战。

  • 发消息是异步流程,需要基于原生或者平台的消息确认事件来确认发送;
  • 每个设备发送消息需要单独建立连接发送消息;
  • 如何保证这样一个复杂剧本能够尽可能合理的演绎出来呢?角色要少,人设要满足。

接下来我们将利用本案例来介绍上述幂等设计方案在实际业务场景中的应用。

【设计】

  • 【去重表设计】剧本消息必须依次发送
    • 剧本任务拆解成若干消息任务,通过 preMsgID 和 nextMsgID 属性组成有序链表:
    • 将 canRun 属性作为执行指针,依次下发消息任务
  • 【乐观锁设计】【分布式锁】【消息队列】执行顺序一致性
    redis setNX分布式锁保证单一进程读取消息队列,不重,不漏,不乱。
  • 【状态机设计】消息状态只能从未执行、执行中、执行成功、执行失败

  • 【唯一标识法】生成唯一消息 UUID,数据库主键去重
  • 【自然幂等】消息队列采用 Redis sorted set,数据原子性操作
  • 【请求指纹】指纹:时间+hash,发送前检查消息的指纹码是否变化

  • 【Token机制】风险机制
    • 发送前检查消息uuid是否合法,避免重发
    • 增加重试机制,token未失效则进行6次重试

结语

本文对高并发、高吞吐、一致性三大挑战提供了一揽子解决方案和方向,希望能够对您在建设服务可靠、配置高效、数据一致的高性能Node服务有所收获。

本文到这里也即将结束,也感谢您认真阅读到这里,也欢迎大家在评论区进行沟通交流。