我们团队在2017年开始大规模使用阿里云函数计算(FC),把很多业务迁移到了函数计算上,比如图片处理、数据同步、定时任务、Webhook处理、API服务等,在这个过程中,积累了一些函数计算架构设计的经验,特别是高可用和高并发方面的经验。

函数计算虽然是Serverless,免运维,自动扩缩容,看起来很简单,把代码上传上去就行了,但是要做到高可用、高并发,还是需要好好设计架构的,不是把代码上传上去就完事了。我们一开始也踩了很多坑,比如函数超时、内存溢出、并发不够、冷启动慢、依赖安装失败、数据不一致等,后来慢慢摸索,总结了一些经验,才把函数计算的架构设计得比较稳定,能支撑高并发,高可用。

今天就来分享一下,我们团队在阿里云函数计算高可用高并发架构设计方面的经验,希望能给大家一些参考。

一、函数计算的基本架构

在讲架构设计之前,先简单介绍一下阿里云函数计算的基本架构,方便大家理解。

阿里云函数计算(Function Compute,简称FC),是阿里云提供的Serverless计算服务,用户只需要编写代码,上传到函数计算,配置触发器,函数计算就会根据请求,自动运行代码,自动扩缩容,用户不需要管理服务器,不需要关心运维,只需要为实际运行的时间付费。

函数计算的基本架构,主要有以下几个组件:

  1. 函数(Function): 用户编写的代码,是函数计算的基本单元,每个函数完成一个特定的功能。函数有运行环境(Node.js、Python、Java、PHP、.NET等)、内存配置、超时时间、环境变量等配置。
  1. 服务(Service): 函数的集合,同一个服务下的函数,可以共享配置,比如日志配置、角色配置、VPC配置等。
  1. 触发器(Trigger): 触发函数运行的事件源,比如HTTP触发器、OSS触发器、定时触发器、日志触发器、表格存储触发器、消息队列触发器等,当事件发生时,自动触发函数运行。
  1. 实例(Instance): 函数运行的环境,函数计算会根据请求量,自动创建和销毁实例,自动扩缩容。每个实例,同时只能处理一个请求(默认),或者多个请求(配置了单实例多并发)。
  1. 冷启动(Cold Start): 函数第一次运行,或者长时间没有请求后,需要创建新的实例,加载代码,初始化运行环境,这个过程叫冷启动,会有一定的延迟。

函数计算的特点:

  • 免运维: 不需要管理服务器,不需要关心操作系统、补丁、扩容等运维问题。
  • 自动扩缩容: 根据请求量,自动扩缩容,请求多的时候自动增加实例,请求少的时候自动减少实例。
  • 按量付费: 只需要为实际运行的时间付费,没有请求的时候不收费,成本很低。
  • 事件驱动: 支持多种触发器,可以很方便地和其他云服务集成,构建事件驱动的架构。
  • 高可用: 函数计算本身是高可用的,多可用区部署,实例故障会自动恢复。

了解了基本架构,下面来讲讲高可用和高并发的架构设计。

二、高可用设计

高可用,是系统设计的基本要求,函数计算虽然本身是高可用的,但是我们的业务代码,也要做好高可用设计,避免因为函数故障、依赖故障、网络故障等,导致业务不可用。

我们总结的函数计算高可用设计,主要有以下几个方面:

1. 多可用区部署: 阿里云函数计算本身是多可用区部署的,实例会分布在多个可用区,单个可用区故障,不会影响其他可用区的实例,所以函数计算本身就具备多可用区高可用的能力。

但是,我们的函数依赖的其他服务,比如数据库、缓存、消息队列等,也要做好多可用区部署,避免因为依赖的服务单可用区故障,导致函数不可用。比如,RDS要开通多可用区,Redis要开通多可用区,消息队列要开通多可用区等。

另外,函数如果访问VPC内的资源,要配置多个交换机,分布在不同的可用区,避免单个可用区的交换机故障,导致函数无法访问VPC内的资源。

2. 重试机制: 函数计算本身有重试机制,对于异步调用的函数,比如OSS触发器、定时触发器、消息队列触发器等,如果函数执行失败,函数计算会自动重试,默认重试3次,间隔一段时间。

但是,对于同步调用的函数,比如HTTP触发器,函数计算不会自动重试,需要我们自己在客户端实现重试机制,比如调用函数失败后,重试几次,间隔一段时间,避免因为临时故障导致请求失败。

重试的时候,要注意幂等性,因为重试可能会导致函数被多次调用,如果函数不是幂等的,可能会导致数据重复、数据不一致等问题。所以,函数要设计成幂等的,或者在重试的时候,做幂等控制。

另外,重试的次数和间隔,要合理设置,不要重试太多次,也不要间隔太短,避免加重下游的压力,甚至导致雪崩。一般重试3次,间隔指数退避,比如1秒、2秒、4秒,比较合理。

3. 降级机制: 当下游的服务故障,或者响应太慢的时候,函数要有降级机制,返回默认值,或者降级的结果,避免因为下游故障,导致函数超时,甚至雪崩。

比如,函数调用第三方API,如果第三方API故障,或者响应太慢,函数可以返回缓存的结果,或者默认值,或者友好的错误提示,而不是一直等待,直到超时。

降级的方式,有很多种:

  • 返回缓存: 下游故障的时候,返回缓存的旧数据,虽然数据不是最新的,但是总比报错好。
  • 返回默认值: 下游故障的时候,返回默认值,比如空列表、默认配置等。
  • 返回友好提示: 下游故障的时候,返回友好的错误提示,告诉用户系统繁忙,请稍后再试,而不是返回500错误。
  • 简化逻辑: 下游故障的时候,简化处理逻辑,只做核心功能,非核心功能暂时关闭,保证核心功能可用。

降级机制,要根据业务的实际情况,合理设计,在保证核心功能可用的前提下,尽量减少故障的影响。

4. 熔断机制: 当下游的服务故障,或者错误率很高的时候,函数要有熔断机制,暂时停止调用下游的服务,直接返回降级结果,避免因为不断调用故障的下游,导致函数超时,资源耗尽,甚至雪崩。

熔断的原理,就像电路的保险丝,当电流太大的时候,保险丝会熔断,保护电路。当下游的错误率超过阈值的时候,熔断器会打开,暂时停止调用下游,直接返回降级结果,过一段时间后,半开状态,尝试调用几次下游,如果成功了,就关闭熔断器,恢复正常调用;如果还是失败,就继续打开熔断器。

熔断的参数,主要有三个:

  • 错误率阈值: 错误率超过多少的时候,打开熔断器,比如50%。
  • 熔断时间: 熔断器打开后,持续多长时间,比如30秒,30秒后进入半开状态。
  • 半开请求数: 半开状态下,允许多少个请求尝试调用下游,比如5个,如果这5个都成功了,就关闭熔断器,否则继续打开。

函数计算里,可以用一些熔断器的库,比如Node.js的opossum,Java的Hystrix、Resilience4j等,来实现熔断机制,也可以自己实现简单的熔断逻辑。

5. 幂等性设计: 函数计算的函数,可能会被多次调用,比如重试、触发器重复触发、网络重发等,如果函数不是幂等的,可能会导致数据重复、数据不一致、重复扣费等问题。所以,函数要设计成幂等的,保证多次调用的结果和一次调用的结果一样。

幂等性的实现方式,主要有以下几种:

  • 唯一ID: 每个请求,带一个唯一的ID,函数处理之前,先检查这个ID有没有处理过,如果处理过了,就直接返回之前的结果,不再重复处理。可以用Redis或者数据库,记录已经处理过的ID。
  • 数据库唯一约束: 插入数据的时候,用唯一索引或者唯一约束,避免重复插入,如果重复插入,数据库会报错,函数捕获这个错误,返回成功即可。
  • 状态机: 用状态机控制业务流程,每个状态只能流转一次,避免重复处理。比如,订单状态,待支付->已支付->已发货->已完成,每个状态只能流转一次,重复调用的时候,检查状态,如果已经流转过了,就直接返回,不再重复处理。
  • 先查后插: 插入数据之前,先查询一下,数据是否已经存在,如果存在,就不插入,直接返回。但是要注意并发问题,高并发下,先查后插可能会有竞态条件,还是要用数据库唯一约束来保证。

幂等性,是高可用设计的重要部分,特别是对于异步调用、有重试的函数,一定要做好幂等性设计,避免数据重复、数据不一致等问题。

6. 超时控制: 函数计算的函数,有超时时间的限制,最长10分钟,如果函数运行超过超时时间,会被强制终止。所以,函数里的各种操作,都要设置合理的超时时间,避免因为某个操作太慢,导致整个函数超时。

比如,函数调用数据库、缓存、第三方API、其他函数等,都要设置合理的超时时间,比如数据库查询超时5秒,第三方API调用超时3秒,不要无限等待,否则如果下游响应太慢,函数会一直等待,直到超时,影响整个函数的执行。

另外,函数的超时时间,也要合理设置,不要设置太短,也不要设置太长,太短的话,函数可能还没执行完就超时了;太长的话,函数故障的时候,会占用资源很长时间,影响并发。一般根据函数的实际运行时间,设置比实际运行时间长一些的超时时间,比如实际运行平均10秒,超时时间设置30秒,比较合理。

7. 错误处理: 函数里的各种操作,都要做好错误处理,捕获异常,不要让异常直接抛出,导致函数崩溃。捕获异常后,要记录详细的错误日志,包括错误信息、堆栈、请求参数等,方便排查问题,然后根据情况,返回友好的错误提示,或者重试,或者降级。

比如,函数调用数据库,如果数据库连接失败,要捕获异常,记录日志,然后重试几次,如果还是失败,就返回降级结果,或者友好的错误提示,而不是直接抛出异常,导致函数返回500错误。

另外,函数的入口,也要做全局的异常捕获,避免因为未捕获的异常,导致函数崩溃。比如,Node.js里,可以用try-catch包裹整个函数逻辑,或者用process.on('uncaughtException')捕获未捕获的异常;Java里,可以用try-catch包裹整个函数逻辑,或者用全局异常处理器。

8. 数据持久化: 函数计算的函数,是无状态的,实例可能会被随时销毁,所以,不要把数据存在函数的内存里,或者临时文件里,要把数据持久化到外部存储,比如数据库、缓存、对象存储、表格存储等,保证数据不丢失。

比如,函数处理过程中产生的中间结果,要及时保存到数据库或者缓存里,不要只存在内存里,否则实例被销毁,数据就丢失了。函数的状态,也要保存到外部存储里,不要存在内存里。

另外,重要的数据,要做好备份,比如数据库的备份,对象存储的备份,避免数据丢失。

三、高并发设计

高并发,是函数计算的优势,函数计算能自动扩缩容,支撑高并发,但是要真正支撑高并发,还是需要做好架构设计,避免因为各种瓶颈,导致并发上不去。

我们总结的函数计算高并发设计,主要有以下几个方面:

1. 并发控制: 函数计算的并发,是有限制的,每个账号,每个区域,默认的并发实例数是100,单个函数的默认并发数也是100,可以通过工单申请提高。如果并发量超过了限制,函数计算会限流,返回429错误。

所以,在设计架构的时候,要评估业务的并发量,提前申请足够的并发数,避免因为并发不够,导致限流。

另外,也要合理控制并发,不要让并发无限制地增长,避免把下游的服务打垮。比如,函数调用数据库,如果并发太高,数据库的连接数可能不够,导致数据库崩溃。所以,可以在函数里做并发控制,比如用信号量,限制同时调用数据库的并发数,或者用消息队列,削峰填谷,把请求缓存到消息队列,消费者慢慢处理。

还有,单实例多并发,函数计算支持单实例多并发,也就是一个实例,同时可以处理多个请求,这样可以提高实例的利用率,减少需要的实例数量,降低成本。但是,单实例多并发,要求函数代码是线程安全的,支持并发处理,否则会有问题。对于I/O密集型的函数,比如调用数据库、第三方API的函数,适合用单实例多并发,可以大大提高并发能力,降低成本。

2. 预热函数: 函数计算的冷启动,会有一定的延迟,特别是Java、.NET等运行时,冷启动时间比较长,可能会有几秒甚至十几秒的延迟,影响用户体验。

对于对延迟敏感的函数,比如HTTP触发器的API函数,可以用预热的方式,避免冷启动。预热的方式,主要有以下几种:

  • 定时预热: 用定时触发器,每隔几分钟,调用一次函数,保持实例不被回收,避免冷启动。这个方法简单有效,但是会增加一点费用,因为定时调用也会计费,不过费用很低,几乎可以忽略。
  • 预留实例: 函数计算提供了预留实例的功能,可以预留一定数量的实例,一直运行,不会被回收,完全避免冷启动。但是预留实例是收费的,即使没有请求也会收费,成本比较高,适合对冷启动要求很高的场景。
  • 单实例多并发: 配置单实例多并发,一个实例可以处理多个请求,这样可以减少冷启动的次数,提高实例的利用率。
  • 初始化逻辑优化: 优化函数的初始化逻辑,减少初始化的时间,比如减少依赖,延迟加载不需要的依赖,把初始化逻辑放在函数外面,只在实例创建的时候执行一次,不要每次请求都执行。

对于非核心的、对延迟不敏感的函数,比如定时任务、数据处理、异步任务等,冷启动的影响不大,可以不用预热,节省成本。

3. 连接池: 函数里调用数据库、缓存、第三方API等,都需要建立连接,如果每次请求都新建连接,会很耗时,也会浪费资源,影响并发能力。所以,要用连接池,复用连接,减少连接的创建和销毁,提高性能和并发能力。

函数计算的实例,是可以复用的,同一个实例,处理多个请求的时候,连接池是可以复用的。所以,要把连接池的初始化,放在函数外面,也就是全局变量的位置,这样实例创建的时候,初始化一次连接池,后续的请求,都复用这个连接池,不要每次请求都新建连接池。

比如,Node.js里,可以在函数外面,初始化数据库连接池,然后在函数里,直接用这个连接池;Java里,可以在静态代码块里,初始化数据库连接池,然后在函数里直接用。

连接池的大小,要合理设置,不要太大,也不要太小,太小的话,并发的时候,连接不够用,请求会等待;太大的话,会占用太多资源,也可能把下游的连接数占满。一般根据函数的并发数,和下游的连接数限制,合理设置,比如单实例并发10,连接池大小设置10-20,比较合理。

另外,连接池要设置合理的超时时间,比如连接超时5秒,查询超时5秒,空闲连接超时30秒,避免连接泄漏,或者连接太久失效。

4. 异步化: 对于耗时的操作,比如发送邮件、发送短信、生成报表、调用第三方API、处理大文件等,不要同步等待,要异步化处理,函数接收请求后,立即返回,后台异步处理,这样可以大大提高函数的并发能力,降低响应时间。

异步化的方式,主要有以下几种:

  • 消息队列: 函数接收请求后,把消息发到消息队列,比如阿里云的消息队列MNS、Kafka、RocketMQ等,然后立即返回,消费者函数从消息队列消费消息,异步处理。这样,函数的响应时间很短,并发能力很强,消息队列还能削峰填谷,应对突发流量。
  • 异步调用: 函数计算支持异步调用,调用函数的时候,设置为异步调用,函数计算会把请求放到队列里,异步执行,调用方立即返回,不需要等待函数执行完成。适合不需要立即返回结果的场景,比如数据处理、定时任务、Webhook处理等。
  • 多线程/协程: 在函数里,用多线程或者协程,并行处理多个耗时的操作,比如同时调用多个第三方API,同时查询多个数据库表,这样可以减少总的处理时间,提高并发能力。比如,Node.js里可以用Promise.all,并行处理多个异步操作;Java里可以用线程池,并行处理多个任务。

异步化,是提高并发能力的重要手段,特别是对于耗时的操作,一定要异步化,不要同步等待,否则会严重影响并发能力。

5. 批处理: 对于大量的数据处理,比如批量插入数据库、批量调用API、批量处理文件等,不要一条一条处理,要批处理,一次处理一批,这样可以大大提高处理效率,减少开销,提高并发能力。

比如,插入数据库,不要一条一条insert,要批量insert,一次插入100条、1000条,这样可以大大减少数据库的交互次数,提高插入效率;调用第三方API,不要一个一个调用,要批量调用,一次调用一批,减少网络开销;处理文件,不要一行一行处理,要批量读取,批量处理,提高效率。

批处理的大小,要合理设置,不要太大,也不要太小,太小的话,效率不高;太大的话,可能会超时,或者占用太多内存。一般根据实际情况,测试一下,找到最优的批大小,比如数据库批量插入,一次100-1000条,比较合理。

另外,批处理的时候,要注意错误处理,如果一批里有一条失败了,是整批回滚,还是跳过失败的,继续处理其他的,要根据业务情况,合理设计。

6. 缓存: 对于频繁访问、变化不大的数据,比如配置信息、用户信息、商品信息、统计数据等,要做缓存,减少数据库的查询,提高响应速度,提高并发能力。

函数计算里,可以用的缓存,主要有以下几种:

  • 实例内存缓存: 把数据缓存在实例的内存里,也就是全局变量,同一个实例处理多个请求的时候,可以复用缓存,减少数据库查询。但是,实例可能会被销毁,缓存会丢失,而且不同的实例,缓存不共享,所以适合缓存变化不大、对一致性要求不高的数据,而且要设置缓存过期时间,定期更新。
  • Redis缓存: 用阿里云的Redis,做分布式缓存,所有的实例都共享这个缓存,缓存的数据不会因为实例销毁而丢失,一致性也更好。适合缓存频繁访问、变化不大的数据,比如用户信息、商品信息、配置信息等。
  • 本地文件缓存: 把数据缓存在实例的临时目录里,也就是/tmp目录,适合缓存一些大的文件,比如模型文件、配置文件等,避免每次都下载。但是,临时目录的大小有限制,最大512MB(可以配置更大),而且实例销毁后,缓存会丢失,不同实例不共享。

缓存的时候,要注意缓存的更新和失效,避免缓存和数据库不一致。可以用缓存失效的策略,比如设置过期时间,定期更新;或者用主动更新的策略,数据更新的时候,主动更新缓存,或者删除缓存,下次查询的时候再加载。

另外,要注意缓存的穿透、击穿、雪崩问题,做好防护,比如缓存空值、布隆过滤器、互斥锁、随机过期时间等。

7. 代码优化: 函数的代码,要做好优化,提高执行效率,减少运行时间,这样同样的实例,能处理更多的请求,提高并发能力。

代码优化的方面,主要有以下几个:

  • 减少依赖: 只引入需要的依赖,不要引入不需要的依赖,减少代码包的大小,加快冷启动速度,减少内存占用。
  • 优化算法: 用高效的算法,避免低效的循环、递归,减少时间复杂度和空间复杂度。
  • 减少I/O操作: 减少数据库查询、文件读写、网络请求等I/O操作,能合并的合并,能缓存的缓存,减少I/O开销。
  • 延迟加载: 不需要的依赖、资源,延迟加载,用到的时候再加载,不要一开始就加载所有的东西,减少初始化时间和内存占用。
  • 内存优化: 避免内存泄漏,及时释放不需要的资源,不要占用太多内存,避免OOM。
  • 运行时选择: 选择合适的运行时,比如Node.js、Python适合I/O密集型的函数,Java适合CPU密集型、复杂业务的函数,根据函数的特点,选择合适的运行时,提高性能。

代码优化,是一个持续的过程,要不断地分析性能瓶颈,针对性地优化,提高函数的执行效率。

8. 数据库优化: 函数的并发能力,很多时候,瓶颈在数据库,数据库的连接数、查询效率、写入效率等,都会影响函数的并发能力。所以,要做好数据库的优化,提高数据库的并发能力。

数据库优化的方面,主要有以下几个:

  • 索引优化: 给常用的查询字段,添加合适的索引,提高查询效率,避免全表扫描。
  • 查询优化: 优化SQL语句,避免复杂的子查询、不必要的JOIN,只查询需要的字段,减少数据传输。
  • 连接池优化: 合理设置数据库连接池的大小,不要太大,也不要太小,避免连接不够用,或者占用太多数据库连接。
  • 读写分离: 读多写少的场景,用读写分离,读请求走从库,写请求走主库,提高数据库的并发能力。
  • 分库分表: 数据量很大的场景,分库分表,把数据分散到多个库、多个表,提高并发能力。
  • 缓存: 用Redis缓存,减少数据库的查询,提高响应速度。
  • 批量操作: 批量插入、批量更新,减少数据库的交互次数,提高效率。

数据库优化,是高并发设计的重要部分,很多时候,函数的并发上不去,不是函数计算的问题,而是数据库的瓶颈,所以一定要做好数据库的优化。

四、性能优化

除了高可用和高并发,性能优化也很重要,函数的性能越好,响应时间越短,用户体验越好,成本也越低,因为函数计算是按运行时间付费的,运行时间越短,费用越低。

我们总结的函数计算性能优化,主要有以下几个方面:

1. 减少冷启动时间: 冷启动,是函数计算性能的一个重要影响因素,特别是Java、.NET等运行时,冷启动时间比较长。减少冷启动时间的方法,前面已经讲过了,比如预热、预留实例、减少依赖、优化初始化逻辑、单实例多并发等,这里就不重复了。

2. 减少代码包大小: 代码包越大,冷启动的时候,下载代码包、解压代码包的时间就越长,所以要减少代码包的大小,只打包需要的文件,不要把不需要的文件、测试文件、文档、node_modules里的devDependencies等,都打包进去。

比如,Node.js里,可以用.npmignore或者.gitignore,忽略不需要的文件,只打包dependencies,不要打包devDependencies;Java里,可以排除不需要的依赖,只打包需要的class文件和依赖。

另外,可以用一些工具,压缩代码,比如Node.js里用webpack、esbuild打包,压缩代码,减少代码包大小;Java里用ProGuard、Spring Boot Thin Launcher等,减少jar包大小。

3. 优化初始化逻辑: 函数的初始化逻辑,比如加载依赖、建立连接、读取配置等,会影响冷启动时间和首次请求的响应时间,所以要优化初始化逻辑,减少初始化的时间。

优化初始化逻辑的方法,主要有:

  • 延迟加载: 不需要的依赖、资源,延迟加载,用到的时候再加载,不要一开始就加载所有的东西。
  • 全局初始化: 把初始化逻辑放在函数外面,也就是全局变量的位置,这样实例创建的时候,初始化一次,后续的请求都复用,不要每次请求都初始化。
  • 异步初始化: 一些不影响首次请求的初始化,可以异步执行,不要阻塞函数的启动。
  • 减少初始化的操作: 只做必要的初始化,不要做不必要的操作,比如不要在初始化的时候,加载所有的配置,查询所有的数据,只加载需要的。

4. 优化函数执行时间: 函数的执行时间,直接影响性能和成本,执行时间越短,性能越好,成本越低。优化函数执行时间的方法,主要有:

  • 减少I/O操作: 减少数据库查询、文件读写、网络请求等I/O操作,能合并的合并,能缓存的缓存。
  • 优化算法: 用高效的算法,减少时间复杂度。
  • 并行处理: 用多线程、协程,并行处理多个操作,减少总的执行时间。
  • 批处理: 批量处理数据,减少交互次数,提高效率。
  • 减少不必要的计算: 只做必要的计算,不要做不必要的操作,比如不要记录不必要的日志,不要做不必要的格式转换。

5. 合理配置内存: 函数计算的CPU,是和内存绑定的,内存越大,CPU越多,性能越好,但是费用也越高。所以,要合理配置内存,找到性能和成本的平衡点。

比如,一个函数,128MB内存,执行时间1000ms,费用是1281000=128000单位;256MB内存,执行时间500ms,费用是256500=128000单位,费用一样,但是性能更好,响应时间更短。所以,有时候,增加内存,反而能降低费用,提高性能。

所以,要测试不同内存配置下的性能和费用,找到最优的配置,不要盲目用小内存,也不要盲目用大内存。

五、成本优化

函数计算虽然按量付费,成本很低,但是如果用不好,费用也可能很高,所以要做好成本优化,在保证性能和可用性的前提下,尽量降低成本。

我们总结的函数计算成本优化,主要有以下几个方面:

1. 合理配置内存和超时时间: 前面讲过,内存和超时时间,直接影响费用,内存越大,超时时间越长,费用越高。所以,要合理配置内存和超时时间,不要配置太大的内存,也不要配置太长的超时时间,根据函数的实际情况,配置合适的内存和超时时间,既能保证函数正常运行,又能降低费用。

另外,可以测试不同内存配置下的费用,找到最优的配置,有时候,增加内存,反而能降低费用,因为执行时间缩短了。

2. 减少执行时间: 函数计算是按执行时间付费的,执行时间越短,费用越低,所以要优化代码,减少执行时间,前面已经讲过了,这里就不重复了。

3. 减少不必要的调用: 函数的调用次数,也会影响费用,虽然每次调用的费用很低,但是调用次数多了,费用也会增加。所以,要减少不必要的调用,比如:

  • 合并函数: 把多个小的函数,合并成一个函数,减少调用次数。
  • 批量处理: 批量处理数据,一次处理一批,而不是一条数据调用一次函数。
  • 缓存: 能缓存的结果,就缓存起来,不要每次都调用函数计算。
  • 过滤不必要的触发: 触发器,比如OSS触发器、日志触发器,要配置过滤条件,只触发需要的事件,不要所有事件都触发函数。

4. 合理使用预留实例: 预留实例,虽然能避免冷启动,但是费用比较高,即使没有请求也会收费。所以,不要盲目使用预留实例,只有对冷启动要求很高、请求比较稳定的函数,才使用预留实例,而且要合理配置预留实例的数量,不要预留太多,避免浪费。

对于请求不稳定、对冷启动不敏感的函数,用按量付费的实例,配合定时预热,成本更低。

5. 合理使用单实例多并发: 单实例多并发,能提高实例的利用率,减少需要的实例数量,降低成本。对于I/O密集型的函数,比如调用数据库、第三方API的函数,适合用单实例多并发,可以大大降低成本。

但是,单实例多并发,要求函数代码是线程安全的,支持并发处理,否则会有问题。而且,单实例多并发的并发数,要合理设置,不要太大,否则会导致实例的CPU、内存不够用,影响性能。

6. 监控费用,及时优化: 要定期查看函数计算的费用账单,分析费用的构成,找出费用高的函数,针对性地优化,比如优化执行时间、减少调用次数、调整内存配置等,降低费用。

阿里云的费用中心,可以查看函数计算的详细费用,包括每个函数的调用次数、执行时间、内存配置、费用等,方便分析和优化。

六、监控告警

高可用和高并发,离不开监控告警,要做好函数计算的监控告警,及时发现问题,及时处理,避免故障扩大。

函数计算的监控,主要有以下几个方面:

1. 函数级监控: 函数计算自带了函数级的监控,包括调用次数、错误率、响应时间、内存使用、并发数等,可以在函数计算的控制台查看,也可以配置云监控告警,当指标异常的时候,及时告警。

重点监控的指标:

  • 错误率: 函数的错误率,超过阈值的时候告警,比如错误率超过1%。
  • 响应时间: 函数的平均响应时间、P95响应时间、P99响应时间,超过阈值的时候告警,比如P99响应时间超过1秒。
  • 并发数: 函数的并发数,接近并发限制的时候告警,比如并发数达到限制的80%。
  • 内存使用: 函数的内存使用率,超过阈值的时候告警,比如内存使用率超过80%,避免OOM。
  • 调用次数: 函数的调用次数,突然增加或者减少的时候告警,可能是异常流量或者故障。

2. 应用级监控: 除了函数计算自带的监控,还要在函数代码里,做好应用级的监控,比如记录业务日志、自定义指标、链路追踪等,方便排查业务问题。

  • 日志: 函数里要记录详细的日志,包括请求参数、响应结果、错误信息、堆栈等,日志输出到函数计算的日志服务里,方便查询和分析。
  • 自定义指标: 对于业务的关键指标,比如订单量、支付成功率、用户注册数等,可以用阿里云的云监控,上报自定义指标,配置告警,及时发现业务异常。
  • 链路追踪: 用阿里云的链路追踪(Tracing Analysis),把函数的调用链路串起来,包括函数调用数据库、缓存、第三方API、其他函数等,方便排查性能问题和故障。

3. 告警通知: 监控告警,要配置合适的通知方式,比如短信、邮件、电话、钉钉、企业微信等,确保告警能及时通知到相关人员,及时处理。

告警的级别,要分清楚,比如P0级别的故障,用电话通知,立即处理;P1级别的故障,用短信和钉钉通知,30分钟内处理;P2级别的故障,用钉钉通知,当天处理,避免告警太多,导致麻木。

另外,告警的阈值,要合理设置,不要太敏感,也不要太迟钝,太敏感的话,会有很多误报,导致麻木;太迟钝的话,故障发现太晚,影响扩大。

七、最佳实践

最后,总结一下阿里云函数计算高可用高并发架构设计的最佳实践:

  1. 函数职责单一: 每个函数,只做一件事,职责单一,不要把太多的逻辑放在一个函数里,这样函数更简单,更稳定,更容易扩展,也更容易测试和维护。
  1. 函数无状态: 函数要设计成无状态的,不要把数据存在内存里或者临时文件里,要把数据持久化到外部存储,比如数据库、缓存、对象存储等,这样函数实例可以随时销毁和创建,不影响数据。
  1. 函数幂等: 函数要设计成幂等的,保证多次调用的结果和一次调用的结果一样,避免因为重试、重复触发等,导致数据重复、数据不一致等问题。
  1. 合理拆分函数和服务: 根据业务的边界,合理拆分函数和服务,不要把所有的逻辑都放在一个函数里,也不要拆得太细,导致函数太多,调用关系复杂。一般按照业务领域拆分,同一个领域的函数,放在同一个服务里。
  1. 事件驱动架构: 充分利用函数计算的事件驱动特性,用触发器触发函数,构建事件驱动的架构,解耦各个组件,提高系统的可扩展性和可维护性。比如,用户上传图片到OSS,触发函数生成缩略图;用户下单,触发消息队列,触发函数处理订单等。
  1. 异步优先: 对于耗时的操作,尽量异步化,用消息队列或者异步调用,不要同步等待,提高函数的并发能力和响应速度。
  1. 缓存优先: 对于频繁访问、变化不大的数据,尽量缓存,减少数据库的查询,提高响应速度,降低成本。
  1. 批处理优先: 对于大量的数据处理,尽量批处理,一次处理一批,提高效率,减少开销。
  1. 做好错误处理和重试: 函数里的各种操作,都要做好错误处理,捕获异常,记录日志,合理重试,避免因为临时故障,导致请求失败。
  1. 做好降级和熔断: 当下游服务故障的时候,要做好降级和熔断,返回降级结果,避免故障扩散,导致雪崩。
  1. 做好监控告警: 做好函数级和应用级的监控,配置合理的告警,及时发现问题,及时处理。
  1. 做好压测: 上线前,要做好压测,测试函数的并发能力、响应时间、错误率等,找到性能瓶颈,针对性优化,确保能支撑预期的并发量。
  1. 做好灰度发布: 新功能上线,要做好灰度发布,先让一部分流量用新的函数,观察一段时间,没有问题,再全量发布,避免故障影响所有用户。
  1. 做好成本优化: 定期分析费用,优化函数的内存配置、执行时间、调用次数等,降低成本。

写在最后

阿里云函数计算架构设计:高可用高并发。

函数计算是Serverless的代表,是未来的发展方向,用好了能大大提高开发效率,降低成本,免运维,自动扩缩容,非常适合事件驱动、突发流量、低频请求等场景。

但是,函数计算不是银弹,不是把代码上传上去就完事了,要做到高可用、高并发、高性能、低成本,还是需要好好设计架构,做好高可用设计、高并发设计、性能优化、成本优化、监控告警等。

本文分享了我们团队在阿里云函数计算高可用高并发架构设计方面的经验,包括高可用设计(多可用区、重试、降级、熔断、幂等、超时控制、错误处理、数据持久化)、高并发设计(并发控制、预热、连接池、异步化、批处理、缓存、代码优化、数据库优化)、性能优化、成本优化、监控告警、最佳实践等,希望能给大家一些参考。

当然,函数计算还在快速发展,新功能不断出现,最佳实践也在不断更新,我们也要不断学习,不断优化,用好函数计算,发挥它的最大价值。

最后,用一句话结尾:

"Serverless不是免运维,而是换了一种运维方式,需要我们从更上层的架构层面,做好高可用、高并发、性能和成本的设计,才能真正发挥Serverless的优势。"

祝大家都能用好函数计算,构建稳定、高效、低成本的Serverless架构!