主题
后端云服务
把 Go 程序搬到云服务器上,只解决了“在哪运行”。一个能长期对外提供服务的后端,还要回答:请求怎样进来,程序怎样访问数据库和文件,谁能调用这些接口,一个机房出问题后还能不能工作,以及流量突然上涨时哪里先撑不住。
计算、VPC、负载均衡、存储、托管数据库、IAM、函数计算,就是沿这条请求链承担不同工作的组件。学习它们,比背产品名更重要的是找出边界:云厂商接手了哪些工作,应用仍然需要负责哪些事情。
本文用 AWS 的官方文档作具体参照,资料于 2026-10-13 核对;示意架构假设在商业分区的一个区域内使用两个可用区,可把区域理解为 us-east-1。这里没有核查实际账户、可用规格、配额或区域价格,不把结论直接外推到中国区、GovCloud 或其他厂商。Lambda 部分限定为普通、短时的事件函数执行模式,不包括 Durable Functions 或 Lambda Managed Instances 等不同运行模式。
本地例子使用 Go 1.27.2、Linux/amd64,全部是容量计算和事件处理模型,没有云账户、云资源、真实数据库或云部署。算术结果是真实运行输出;延迟、故障恢复时间与实际账单都没有实测。
一次请求,会经过哪些服务
假设我们做一个文件处理服务:用户提交任务,Go API 把任务记录写入数据库,文件放在对象存储里;后台处理者接收事件,生成处理结果。下面是一种可行的组织方式,不是每个项目都需要照搬的部署方案。
正在绘制图示…
查看图源代码
flowchart TB
U["客户端"] -. "DNS 查询" .-> DNS["DNS\n找到公开入口"]
DNS -. "返回入口地址" .-> U
U -->|HTTPS 请求| LB["公网负载均衡\nHTTPS 监听、TLS 证书、转发规则"]
subgraph R["同一区域内的 VPC:逻辑隔离的网络"]
LB
subgraph A["可用区 A"]
APP1["私有子网:Go API 副本"]
DB1["数据库主实例"]
end
subgraph B["可用区 B"]
APP2["私有子网:Go API 副本"]
DB2["数据库备用实例"]
end
LB --> APP1
LB --> APP2
APP1 --> DB1
APP2 --> DB1
DB1 -. "本例选择同步备用复制" .-> DB2
end
APP1 --> OBJ["对象存储服务端点\n文件与处理结果"]
APP2 --> OBJ
APP1 --> Q["事件 / 队列"]
APP2 --> Q
Q --> F["后台任务或函数\n读取文件、写入结果"]
F --> OBJ
F --> DB1
ID["工作负载身份 + IAM 策略"] -. "授权服务 API 调用\n不是数据包必经网关" .-> OBJ
ID -. "授权" .-> Q
图中的 DNS 虚线表示地址解析;HTTPS 请求由客户端直接发给负载均衡,DNS 不转发这些请求。图中省略了路由网关、网络接口和具体子网分配。负载均衡跨可用区配置,后端副本分散放置;“有两个应用副本”本身不代表一定跨了故障域。对象存储是通过服务端点访问的托管服务,也不能因为应用在 VPC 里,就把整个对象存储画成应用的一块本地磁盘。
| 类别 | 在请求中承担什么工作 | 应用侧最容易漏掉的责任 |
|---|---|---|
| 计算 | 运行 API、消费者、定时任务 | 进程限制、依赖版本、扩缩容、退出与请求排空 |
| VPC 与路由 | 决定地址空间及网络怎样到达 | 路由、防火墙、DNS、出站依赖和地址容量 |
| 负载均衡 | 接收连接,选择后端,做健康检查 | 检查路径、后端协议、超时、信任头与业务健康 |
| 存储 | 持久保存对象、文件或块数据 | 数据组织、访问权限、版本、删除与恢复策略 |
| 托管数据库 | 提供数据库引擎及部分运行维护 | 表结构、SQL、连接预算、备份保留、恢复和故障切换验证 |
| IAM | 判断哪个身份可对哪个资源做什么 | 最小权限、身份生命周期、审计与应用用户鉴权 |
| 函数计算 | 根据事件运行代码,管理执行环境 | 冷启动、超时、并发、事件重复和外部持久状态 |
云服务减少的是某些层次的维护工作。以 EC2 为例,云厂商负责底层基础设施,客户仍负责实例内的操作系统和应用;换成 S3 这样的托管服务后,底层操作系统不再由客户维护,但数据、权限和使用方式仍由客户决定。这就是共享责任模型。责任随服务抽象层次变化,不会因为出现“托管”两个字而全部转走。1
先看故障域:区域、可用区与复制不是同一个概念
区域(Region)是一个地理区域,里面有多个可用区(Availability Zone,AZ)。AWS 的可用区由一个或多个独立数据中心组成,用来隔离部分设施故障;普通 VPC 横跨所在区域,而一个子网属于一个可用区。2 3
为什么这个区分重要?如果两个 Go 实例在同一个可用区,失去一台实例时另一台可能接手,但该可用区出现故障时,两台可能一起不可用。把副本放进不同可用区,可以减少某些共同故障,却还需要负载均衡、容量和数据层一起配合。故障后只剩一半计算资源,如果这部分资源平时已接近满载,“还有副本”仍可能变成全站过载。
跨区域部署再扩大了隔离范围,也增加了复制延迟、路由切换、数据一致性、演练和费用问题。不能把两个区域各放一台数据库就称为已经实现容灾:发生切换时,谁接收写入?可能丢多少已确认数据?旧主恢复后怎样避免同时写?这些问题要由数据库和应用的恢复协议回答。
| 目标 | 要验证什么 | 增加副本为什么不一定解决 |
|---|---|---|
| 单实例故障时继续服务 | 剩余实例的容量、流量摘除与连接重建 | 副本可能共享同一瓶颈 |
| 可用区故障时继续服务 | 跨区放置、数据库切换、区域内依赖 | 副本都在同一区,或依赖仍为单点 |
| 区域故障后恢复 | 跨区域数据与配置、切流、恢复演练 | 另一地区有计算资源,不代表有可用数据 |
| 误删除后找回数据 | 历史版本、备份保留与恢复权限 | 实时复制可能很快把错误也复制过去 |
这里的判断来自故障范围推理,不是某个具体部署的可靠性测量。实际能承受哪一类故障,应由演练结果证明。
计算:扩的是整个服务,还是只有 CPU
计算产品首先决定你能控制到哪一层。虚拟机给你较完整的操作系统控制,容器平台围绕镜像和任务管理,函数平台则围绕一次调用的入口和生命周期管理。1 4 5 20
| 运行方式 | 你交付什么 | 控制与维护范围 | 擅长解决的问题 | 需要承担的代价 |
|---|---|---|---|---|
| 虚拟机 | 系统镜像、程序和启动配置 | 可管理 OS、进程、网络栈配置,维护面较大 | 常驻服务、特殊系统依赖、细粒度调优 | OS 补丁、实例替换、容量与启动管理 |
| 托管容器运行环境 | 容器镜像、资源与网络配置 | 主要管理应用、任务配置,具体责任取决于平台 | 已容器化的常驻 API、后台任务 | 镜像与依赖维护、资源限制、平台约束 |
| 普通事件函数 | 函数包或支持的镜像、事件入口 | 管代码和运行配置,平台管理执行环境 | 事件驱动、可拆分的短任务、波动调用 | 调用限制、冷启动、外部状态、触发器语义 |
容器不天然等于“无需管机器”:自己运行 Kubernetes 节点时,节点维护仍有工作;采用 Fargate 一类托管运行环境时,责任又不同。同样,Serverless 也不只包括函数,某些容器或数据库服务可以采用类似的资源管理方式。不能仅根据交付物是不是容器判断责任。
选规格时,vCPU 和内存是入口,但不是完整性能描述。实例类型还影响处理器架构、网络能力、磁盘吞吐和 I/O。Go 服务如果主要等待数据库,即使 CPU 利用率不高,也可能已经被连接池或数据库锁卡住。加 CPU 无法直接消除这个瓶颈。4
对 Go 程序还要区分几件事:给容器设置内存限额,不等于为应用堆留下了同样多的空间;把可运行 goroutine 的并行度调整到合理范围,不会改变外部数据库吞吐;二进制的 GOARCH、CGO 依赖和运行镜像也要与计算环境匹配。这些程序层面的约束仍要由应用团队测试,不能从“云实例启动成功”推出程序已经适配。
扩容会放大下游连接数
假设一个服务最多扩到 50 个实例,每个实例的数据库连接池最多打开 5 条连接。数据库允许本例设定的 150 条连接,其中 30 条留给运维和其他程序。这些全是教学假设,不是 RDS 某规格的默认值。
go
connections := maxInstances * poolPerInstance
budget := dbMax - dbReserve
fitInstances := budget / poolPerInstance完整程序对输入和整数溢出做了检查。实际输出:
text
pool-envelope=250 budget=120 fit-instances=24 over-budget=true这不是在建议把实例数设成 24。它只说明:在“每个实例各有一个池,池都可能开满,其他连接已包含在预留值里”的模型中,50 个实例的连接上限合计为 250,超过 120 的预算;24 个实例才符合这一条约束。24 个实例是否能处理业务流量,还要另做压测。
Go 的 sql.DB 是连接池,SetMaxOpenConns 限制的是这个池的打开连接数,不是所有服务实例的总连接数。若同一进程创建多个池,或故障切换时新旧实例重叠,预算还要包括它们。数据库代理可以复用部分连接,但长事务、会话状态和特定请求会限制复用效果,不能把代理当成无限连接转换器。6 7 40
VPC 和负载均衡:能连通与有权限,要分开检查
VPC 是逻辑隔离的网络,不是一条“里面的程序都可信”的规定。一个请求成功,通常至少需要地址和路由正确、网络规则允许、传输协议匹配、身份有权限,以及应用本身接受请求。3
子网的“公有 / 私有”来自路由与地址条件
在 AWS 的常见 IPv4 架构中,公有子网有通向 Internet Gateway 的路由。实例要直接经该路径访问互联网,还需要合适的公网地址等条件。私有子网里的程序若要访问公网依赖,通常需要 NAT 等出站路径;访问支持的 AWS 服务也可以按场景使用 VPC endpoint。NAT 解决地址转换,不授予 S3、数据库或其他 API 的权限。8
| 控制点 | 判断什么 | 常见误解 | 排查时看什么 |
|---|---|---|---|
| 路由表 | 目标地址的流量应送到哪里 | 有私网 IP 就能访问所有私网 | 目标网段、下一跳、返回路径 |
| 安全组 | 关联资源的哪些流量可以进入或离开 | 开放 TCP 端口等于业务已授权 | 源 / 目标、协议、端口及关联的资源 |
| 网络 ACL | 子网边界的允许 / 拒绝规则 | 返回流量会自动被允许 | 双向规则与临时端口范围 |
| DNS | 服务名解析成什么地址 | DNS 正常就等于连接正常 | 解析结果、缓存和环境中的解析路径 |
| TLS / 应用鉴权 / IAM | 对端是谁,能做什么 | 内网不需要认证与加密设计 | 证书、身份、策略、应用级授权 |
AWS 安全组是有状态的,允许的连接的响应流量会按其连接跟踪语义处理;网络 ACL 是无状态的,返回方向要有相应规则。安全组支持允许规则,网络 ACL 还支持显式拒绝。这些是 AWS 具体机制,不能把其他厂商同名功能自动视为相同。9 10
一种常见的权限收窄方式是:公网只到负载均衡的监听端口;应用端口只接受负载均衡对应来源;数据库端口只接受应用对应来源。这样减少直接暴露面,但仍需要数据库账号权限和应用用户鉴权。来源安全组限制也有具体网络拓扑条件,不是跨所有网络都可使用的任意身份标签。11
负载均衡把请求送过去,不会替应用完成事务
负载均衡器通常包括监听器、路由规则、目标组和健康检查。以 ALB 为例,可以在 HTTP 层按主机名或路径选择目标组,再把请求发给其中的后端。四层与七层负载均衡关注的协议层次不同:四层主要围绕传输连接,七层才能理解 HTTP 路由语义;实际支持的协议、TLS 模式和源地址行为需要查具体产品。12 37 38
TLS 在入口终结以后,入口到后端使用什么协议仍需独立决定。若使用转发头传递原始客户端信息,应用只能信任经过约定代理路径生成的头,不能直接相信任何客户端提交的同名头。39
健康检查通常通过一个路径、协议和阈值判断目标是否可选。它只看你定义的信号。如果路径永远返回 200,数据库全坏了也可能显示健康;如果路径把每一个可选依赖都算成必须项,某个非关键依赖异常又可能摘除所有后端。检查应体现“这个实例还能否接受当前流量”,并配合真实请求成功率。13
AWS ALB 还有一个容易被忽略的具体边界:目标组里注册的目标全部不健康时,可能以 fail-open 方式继续向这些目标转发。所以“健康检查失败就绝不会收到请求”不是可靠假设。13
长连接、正在处理的请求和数据库事务也不会因为目标被摘除而瞬间完成。发布时需要停止接收新流量、等待在途请求、设置合理的排空窗口,再结束 Go 进程。客户端超时与服务端提交事务之间还可能出现不确定性:客户端没收到成功响应,不表示事务一定没提交,后面的重试必须考虑幂等。
存储:先确认访问语义,再看容量和价格
把“存储”看成同一种东西,最常见的结果是把对象存储当文件系统用,或者把单可用区的磁盘副本误认为跨区备份。
| 类型 | 应用看到的接口 | 适合存什么 | 主要好处 | 主要限制与维护点 |
|---|---|---|---|---|
| 块存储,例如 EBS | 块设备,上面由系统或应用组织数据 | 数据库数据目录、需要文件系统语义的磁盘 | 可按块读写,容量与性能可配置 | 可用区、挂载方式、文件系统一致性、快照 |
| 文件存储,例如 EFS | 网络文件系统接口 | 多客户端共享的文件树 | 共享目录和既有文件接口较自然 | 网络与元数据延迟、吞吐、挂载和文件权限 |
| 对象存储,例如 S3 | bucket、key 和对象 API | 上传文件、结果文件、制品、备份对象 | 无需把每个对象放进自管磁盘目录 | 不能直接等同本地 POSIX 文件系统;请求、生命周期与权限需设计 |
| 临时本地空间 | 实例或函数环境内的文件路径 | 缓存、可重建中间文件 | 访问方便,减少重复下载 | 生命周期依赖运行环境,不能作为唯一持久副本 |
这个比较依据的是访问模型,不承诺哪种对所有负载都最快。块存储还受卷类型、IOPS、吞吐和实例带宽共同约束;文件系统涉及网络与共享访问;对象 API 的请求大小、次数和并发方式则会影响吞吐与费用。14 15 16
持久性、可用性、一致性、可恢复性,分别问四个问题
- 持久性:已经保存的数据会不会永久丢失?
- 可用性:现在能不能读写它?
- 一致性:写成功以后,其他读取能看到什么?
- 可恢复性:误删、错误覆盖或破坏后,能恢复到哪个状态?
这四个问题不能互相替代。AWS 文档说明 EBS 会在一个可用区内跨服务器复制数据,这不等于卷天然跨可用区继续挂载。S3 对相关对象读写提供强一致性,也不意味着应用可以把多个对象更新和数据库事务视为一个原子提交。启用 S3 Versioning 可以保留对象版本,但还要设计旧版本的保留、删除权限和生命周期,否则“曾经有旧版本”不保证需要时仍找得回来。14 16 17
以文件处理任务为例,数据库记录与对象文件是两个系统。若先写数据库再上传文件,上传失败会留下一个指向不存在对象的记录;反过来上传成功但数据库失败,则可能留下无人引用的对象。可以用 pending / ready 状态、可重复执行的任务和定期清理来处理这些中间状态。这个设计由应用负责,托管存储不会替你形成跨系统事务。
正在绘制图示…
查看图源代码
stateDiagram-v2
[*] --> Pending: 接收任务并记录待上传
Pending --> Ready: 对象写入成功并确认元数据
Pending --> Pending: 上传失败,可按任务 ID 重试
Pending --> Failed: 超时或人工判定放弃
Ready --> Processing: 后台领取
Processing --> Complete: 结果对象和任务状态协调完成
Processing --> Ready: 可重试失败
Complete --> [*]
Failed --> [*]
图里每一个箭头都需要定义重试和幂等条件。例如“结果已写入但 Complete 更新失败”时,重试必须能复用或识别已有结果。图本身不是一个已实现的可靠协议;它说明应用需要补齐哪些状态,而不是把中间失败隐藏掉。
托管数据库:主机运维减少了,数据责任还在
托管数据库通常代管底层基础设施、部分补丁、备份和故障转移流程。你仍然决定表结构、索引、查询、隔离级别、连接池、容量、引擎升级节奏,以及哪些数据需要恢复到什么时候。慢 SQL 不会因为数据库由云厂商托管而自动变快。18
最容易混淆的是下面三种能力。这里明确用 RDS 的“Multi-AZ DB instance 单备用实例部署”作例子,不把它与具有可读副本的 Multi-AZ DB cluster 或 Aurora 混为一谈。19
| 能力 | 主要解决的问题 | 数据怎样变化 | 不能替代什么 |
|---|---|---|---|
| Multi-AZ DB instance 的备用实例 | 主实例或可用区故障后的可用性 | 跨可用区同步复制,备用实例用于故障转移 | 该部署中的备用实例不提供读扩展;不能找回已复制的误删 |
| DB instance read replica | 把一部分读取分散出去 | 常见支持引擎采用异步复制,可能有延迟 | 不保证读到刚写入的数据;不等同所有 HA 方案 |
| 备份 / 时间点恢复 | 回到保留范围内的历史状态 | 由备份与日志恢复出数据 | 不等于立即接管在线流量;恢复需要时间与核对 |
同步复制让确认写入的路径包含跨区协调,可能增加提交延迟;异步只读副本把读吞吐扩出来,又带来复制延迟。两者优化的目标不同。19 21
RDS 的时间点恢复会创建一个新实例,不是把原实例原地倒退。恢复后还要确认数据、网络规则、参数、应用连接和切换步骤。备份保留窗口、最新可恢复时间和恢复吞吐都会影响实际结果,不能把“已打开自动备份”直接换成一个未经测量的恢复承诺。22
这里会用到两个指标:RPO 是能接受的数据丢失时间范围,RTO 是能接受的恢复耗时。需求若是“最多丢几分钟数据,服务应在某个时间内恢复”,需要把复制或备份能力、故障发现、人工决策、恢复、验证和切流都算进去。本文没有测量任何实际 RPO / RTO。
故障切换还会影响现有连接。应用应能识别失效连接、按当前端点重建,并在有界重试中避免重复执行不安全的写入。监控也不能只看 CPU:数据库连接数、存储余量、I/O 延迟、复制延迟和查询等待,往往更接近瓶颈。18
IAM:让运行中的程序拿到恰好够用的身份
IAM 解决的是主体(principal)对资源执行动作的授权。主体可以是人,也可以是应用承担的角色。角色的信任关系决定谁能取得这个角色身份;权限策略决定取得身份后能做什么。两者是不同的问题。36
对运行在云里的程序,官方建议尽量使用角色和临时凭证,避免把长期访问密钥写进源码或镜像。执行身份应按工作负载拆分:只读文件的任务不需要删除数据库的权限,生产服务也不应复用开发者个人管理员身份。23
下面是一个仅供阅读和本地 JSON 语法检查的身份策略。它没有附加到任何真实角色,也没有提交给 AWS:
json
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::example-demo-artifacts/releases/*"
}它表达的范围是:允许读取这个示意 bucket 中 releases/ 前缀下的对象。s3:GetObject 的对象 ARN 与 s3:ListBucket 的 bucket ARN 不同,这段 Allow 本身不授予列出 bucket、写对象或删对象的权限。使用 KMS 加密等功能时还可能涉及其他权限;不能只看这一条就保证读取一定成功。24
网络可达、身份有效、动作被授权,是不同检查。 私有子网里的程序访问 S3 得到 403,可能是 IAM、bucket policy、组织策略、端点策略或加密权限等原因;把安全组开得更宽未必解决问题,还可能扩大暴露面。应用用户能否下载别人的文件,又是你自己的业务鉴权,不能只靠服务进程的 IAM 身份判断。
AWS 的有效权限由适用的多类策略共同评估,显式 Deny 会覆盖相关 Allow;permissions boundary 和组织级控制还能限制权限。跨账户、角色会话、资源策略等情形有进一步规则。把所有情况简化为“把 Allow 求并集”会出错。本文没有实现 IAM 策略求值器,本地测试只确认示例是 JSON,不能据此断言某个账户的访问结果。25
函数计算:执行环境可以复用,业务状态不能靠它保住
普通事件函数把“启动服务器并一直监听”变成“平台收到事件后调用入口”。执行环境仍然存在:平台要准备运行时,加载程序,运行初始化代码,然后调用处理函数。已有环境可能被复用,也可能被冻结、重置或回收。20
正在绘制图示…
查看图源代码
stateDiagram-v2
[*] --> Init: 新执行环境
Init --> Invoke: 运行时与初始化完成
Invoke --> Frozen: 本次调用结束,环境可能保留
Frozen --> Invoke: 复用环境处理后续调用
Frozen --> Shutdown: 回收
Invoke --> Reset: 崩溃或超时等失败
Reset --> Init: 平台可能重新初始化
Shutdown --> [*]
这个图只解释本文限定的普通执行模式,不覆盖 Durable Functions 的等待与持久检查点,也不覆盖 Managed Instances 的并发运行模型。20
复用有实际好处:数据库客户端、HTTP transport 和可以重建的缓存,可以在适当的初始化位置创建,减少每次调用的重复工作。但是进程内 map、全局变量和 /tmp 都不应成为任务完成记录的唯一副本。平台可能保留它们,所以要防止把上一请求的敏感数据泄漏给下一请求;平台也可能不保留它们,所以不能靠它们提供持久性。20 26
同样,handler 返回之后启动的后台 goroutine 不能被当成可靠任务队列。执行环境可能被冻结。需要在返回成功之前完成必须持久化的动作,或者把后续工作可靠交给外部队列和持久状态。
若函数需要访问私有数据库,还要配置相应 VPC 连接、路由与安全规则。把 Lambda 连接到公有子网,不会自动给它公网 IP 或互联网访问能力;它的出站路径仍要单独设计。函数平台接管了执行环境,没有替你取消网络边界。35
冷启动与预置并发,优化的是不同的等待
冷启动包含准备执行环境与初始化代码。包大小、依赖、初始化工作、运行时和网络连接建立都会影响一次调用的启动路径,不能给“Go 函数冷启动”写一个对所有项目都适用的毫秒数。把大型初始化放在每次 handler 调用里,也不会因为环境复用自动省掉它。
Lambda 的 reserved concurrency 为函数保留一部分并发额度,同时设定该函数的并发上限;它不意味着提前把这些环境都初始化好。provisioned concurrency 才用于提前准备环境,减少冷启动等待,并有额外计费。流量超出预置覆盖范围时怎样处理,还要结合函数的并发限制和调用路径判断。27
平均并发量可以从吞吐和持续时间估计。假设系统处于稳态,每秒处理 200 次调用,每次平均占用执行时间 200 毫秒:
go
meanInFlight := float64(rate*meanDurationMS) / 1000本地模型输出:
text
mean-in-flight=40.0 (not a peak bound)这是平均值,不能拿 40 作为不丢请求的并发保证。突发流量、长尾耗时、重试和下游变慢都会增加所需容量。AWS 文档也把这个公式当作估计,并要求结合实际并发指标和上下游约束。27
函数扩容同样可能放大数据库连接数。每个执行环境各建一个连接池时,不能只看某个池的最大连接数。前面的“实例数乘以每实例池大小”能帮助检查一种假设情形,但 Lambda 的预热、冻结环境和连接存活时间并不简单等于当前并发调用数;不能据此声称设置 reserved concurrency 就严格限制了所有残留连接。仍需实际连接指标、池生命周期和必要的代理策略。
平台超时不撤销已经发生的外部动作
本文限定的普通 Lambda 函数,官方配置文档给出的超时上限是 900 秒。其他运行模式另有规则,所以这里不把“15 分钟”当成全部 Serverless 产品的定义。28
把上限设大只能允许更长时间的执行,并不代表客户端愿意等这么久。一个同步 HTTP 调用往往还受客户端、入口和下游超时约束。假设客户端只等 3 秒,而函数 10 秒后才超时,函数可能在客户端断开后继续写数据库。下一次客户端重试时,就遇到了“上一次到底完成没有”的问题。
Go 代码应把调用链的 context 传给支持取消的下游操作,并让下游超时小于剩余预算,给返回结果留时间。即便如此,取消也不是事务回滚指令:请求取消时,远端可能已经提交。解决这种不确定性需要业务幂等和结果查询,不能只靠一个超时参数。
重试规则取决于事件从哪来
| 调用方式 | 谁负责重试或再次投递 | 应用必须考虑什么 |
|---|---|---|
| 同步调用 | 调用者及其 SDK / 上游服务按自己的规则处理 | 调用失败的返回、限流、超时与不确定结果 |
| Lambda 异步调用 | Lambda 管理异步队列和相应重试 | 事件可能重复、过期或最终失败,需要失败去向 |
| SQS event source mapping | 轮询器与队列的可见性、批处理机制共同作用 | 重复处理、可见性超时、批次部分失败、死信处理 |
不能把异步函数默认重试次数套到 SQS 消费上,也不能把“代码没报错”当成绝不重投。AWS 文档明确提醒,异步队列可能多次交付同一事件,SQS 事件源处理也要考虑至少一次和重复记录。批处理函数应按对应协议报告失败项,否则一个失败记录可能导致同批已处理记录再次出现。29 30 31
失败去向不是任务自动完成器。死信队列或 on-failure destination 保存了需要调查的事件以后,还要有人或程序识别永久错误、修复原因、控制重放速度,并继续使用同一个幂等约束。
用一个本地反例看清:为什么去重必须和业务变化一起提交
假设一次事件要把库存从 10 减去 2。这个库存只是本地教学整数,不涉及真实订单。事件 ID 是 evt-42,重复交付使用同一 ID 和同一参数。
下面是模型中的关键路径,完整实现使用互斥锁,把检查、库存变化和完成标记放在同一个临界区里:
go
if units, ok := l.done[e.ID]; ok {
if units != e.Units {
return false, errors.New("event ID payload conflict")
}
return false, nil
}
l.stock -= e.Units
l.done[e.ID] = e.Units实际运行的输出:
text
first: applied=true stock=8 err=<nil>
redelivery: applied=false stock=8 err=<nil>
conflict: event ID payload conflict重复事件没有再次扣减。相同 ID 却带不同参数时,模型拒绝它,避免把 ID 冲突悄悄当成重复成功。测试还让 100 个 goroutine 同时处理相同事件,检查最终只发生一次变化,并运行了 race 检查。
这仍然不是可部署的函数幂等方案:互斥锁只协调这个 Go 进程里的线程,map 没有持久性,也不协调另一台机器。模型进一步假设业务库存 8 已保存在外部,而新工作者丢失了去重历史,重新处理同一事件,输出变成:
text
new-worker-without-dedup: applied=true stock=6这解释了为什么把去重 map 放进函数全局变量不够。生产中通常需要持久的唯一键或条件写入,并让去重记录、必要的返回结果与业务变化处于同一个事务边界。若副作用跨到另一个系统,单个数据库事务又不够,需要 outbox、目标系统的幂等键或可核对的状态机;这些属于应用协议设计。
另一个反例先保存“事件已完成”,再做业务变化。模型在两步之间注入中断,随后重试,得到:
text
bad-mark-first retry: applied=false stock=10库存没变,事件却再也不执行了。如果反过来先改库存再写完成标记,中间中断又会造成重做。正确边界必须同时覆盖“做了什么”和“怎样证明已经做过”。本地模型只验证这个逻辑,没有模拟真实数据库崩溃、持久化、网络分区或跨服务事务。
正在绘制图示…
查看图源代码
sequenceDiagram
participant Q as 事件来源
participant W as 工作者
participant D as 持久事务存储
Q->>W: 事件 ID + 参数
W->>D: 事务内核对 ID / 参数
alt 已有相同完成记录
D-->>W: 返回已有结果
else 尚未完成
W->>D: 在同一事务内写业务变化和完成记录
D-->>W: 提交成功
end
W-->>Q: 确认处理成功
Note over Q,D: 提交后确认丢失仍可能重投,但相同事件可以读取既有结果
图中的持久事务存储是生产设计应有的边界;本文 Go 程序只是在单进程内表达这条边界,不是它的替代实现。
成本:先列计量单位,再讨论哪个方案便宜
“按量付费”没有回答按什么量付费。不同服务可能按运行时间、请求次数、配置的内存与时长、存储容量、IOPS、数据处理量或传输方向收费。架构里多一个 NAT、代理、日志链路或跨区调用,计量项就可能增加。比较方案时,需要对同一个业务量、可用性和保留要求计算完整路径。32 33 34
| 组件 | 应核对的计量维度 | 常见遗漏 |
|---|---|---|
| 虚拟机 / 容器 | 规格、运行时长、计费最小粒度、购买方式 | 空闲常驻容量、扩容重叠时间 |
| 普通函数 | 调用次数、计费时长、内存档位、预置容量等 | 初始化与重试、架构 / 区域差异、额外配置 |
| 数据库 | 实例 / 容量、存储、I/O、备份、复制 | HA 资源、连接代理、恢复验证时的额外实例 |
| 对象与文件存储 | 容量、请求、读取或检索、存储类别 | 小文件大量请求、旧版本、提前删除或最低保留规则 |
| 网络与入口 | 负载均衡、NAT、处理量、传输方向和路径 | 跨区、跨区域、公网出站、重复传输 |
| 可观测性 | 日志摄入与保留、查询、指标和追踪 | 对每次重试重复记录大对象 |
这些是核账清单,不是对某个产品承诺全部项目都会收费。具体费率与免除规则须查目标区域、架构、存储类别、合同和账单日期。本文没有给出任何货币单价,也不能据此断言 Serverless 总比常驻服务便宜。
可以先做不含价格的单位检查。假设一百万次调用,每次使用 512 MiB,且每次用于这个模型的计费时长都为 200 毫秒:
text
1,000,000 × (512 / 1024) × (200 / 1000)
= 100,000 GiB·秒本地程序输出:
text
modeled-memory-GiB-seconds=100000 (not a money quote)这里明确使用 1024 MiB = 1 GiB 的教学归一化口径,只算给定输入的内存乘时长。真实账单需要按厂商单位、舍入粒度、初始化、免费额度、阶梯、额外服务等重新计算;程序没有做这些事。它把平均服务时长和计费用时分成两个字段,正是因为“处理请求平均花多久”不能自动代替“最终按多久计费”。
扩大函数内存可能增加 CPU 资源并缩短运行时间,也可能只是增加未使用的容量。内存乘时长到底变大还是变小,需要针对该函数和架构测量。相同道理,选择更便宜的存储类别可能增加检索等待和请求费用,低单价不能代替总成本比较。26 32 34
真正准备上线时,要拿到哪几种证据
先沿请求链检查,而不是逐个点亮控制台上的绿色状态。入口 TLS 正常,不能证明后端有数据库权限;数据库正在运行,不能证明应用能在主实例切换后恢复;函数执行一次成功,也不能证明重复事件安全。
| 要确认的结果 | 应取得的证据 |
|---|---|
| 请求能完成 | 从客户端到结果存储的完整成功 / 失败路径,含关联 ID |
| 权限足够且不过宽 | 运行身份、适用策略、明确允许与拒绝的实际验证 |
| 负载能承受 | 代表性压测、长尾延迟、并发、连接数、排队与限流指标 |
| 单点失败可恢复 | 实例 / 可用区 / 依赖故障演练及恢复后的业务核对 |
| 数据可以找回 | 从保留备份实际恢复、检查关键数据,并记录耗时 |
| 重试不会破坏结果 | 重复、乱序、超时、提交后确认丢失等故障场景 |
| 花费可解释 | 目标区域当前费率、业务量假设、完整计量项和实际账单比对 |
选择云服务时,最有用的问题是“它替我完成了哪段工作,失败以后哪段还得由我接上”。沿着这个问题把请求、身份、数据和恢复路径连起来,产品名才会变成一套可以运行和维护的后端。
依据与验证范围
资料核对日期为 2026-10-13。AWS 文档持续更新,以下产品细节均限定为对应页面描述的服务与模式;没有从某个产品推断所有云平台。容量参数、库存数量与单位计算均为文中明确列出的教学假设。
Go 连接池行为还用本机 Go 1.27.2 的 go doc database/sql.DB.SetMaxOpenConns 核对。IAM 文件只经 JSON 语法检查,没有运行 AWS IAM Simulator,也没有授予权限。本地单元测试、并发重复事件测试、race、vet、无效输入反例都已运行;没有把这些模型结果当成 Lambda 吞吐、数据库持久性、真实事件队列交付保证或服务价格的实测。
完整本地实验
需要 Go 1.27.2 与 Bash,无第三方 Go 依赖,无云 SDK,无账号和凭证。将下列文件保存到同一目录,运行 bash verify.sh。程序只读本地 JSON,执行算术和内存模型;不会访问云平台或改变 IAM。
workload.json 是教学输入,不是任何云产品的部署配置。iam-read-example.json 是策略形式示例,不应未经目标账户的权限设计和验证就附加到真实身份。fixtures/invalid.json 是故意错误的输入,脚本检查它确实以 2 退出。
go.mod
go.mod
module example.com/cloudmodels
go 1.27.0main.go
代码块 · 169 行 · 点击收起 / 展开
go
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"math"
"os"
"sync"
)
type Workload struct {
Requests int64 `json:"requests"`
MemoryMiB int64 `json:"memory_mib"`
BilledMS int64 `json:"billed_ms"`
Rate int64 `json:"requests_per_second"`
MeanMS int64 `json:"mean_duration_ms"`
MaxInstances int64 `json:"max_instances"`
PoolPerInstance int64 `json:"pool_per_instance"`
DBMax int64 `json:"db_max_connections"`
DBReserve int64 `json:"db_reserved_connections"`
}
type Result struct {
MeanInFlight float64
GiBSeconds float64
Connections, Budget, FitInstances int64
}
func mul(a, b int64) (int64, error) {
if a < 0 || b < 0 || (b != 0 && a > math.MaxInt64/b) {
return 0, errors.New("negative value or integer overflow")
}
return a * b, nil
}
func Analyze(w Workload) (Result, error) {
if w.Requests <= 0 || w.MemoryMiB <= 0 || w.BilledMS <= 0 || w.Rate <= 0 || w.MeanMS <= 0 || w.MaxInstances <= 0 || w.PoolPerInstance <= 0 || w.DBMax <= 0 || w.DBReserve < 0 || w.DBReserve >= w.DBMax {
return Result{}, errors.New("positive inputs required; DB reserve must be below DB max")
}
units, err := mul(w.Requests, w.MemoryMiB)
if err != nil {
return Result{}, err
}
units, err = mul(units, w.BilledMS)
if err != nil {
return Result{}, err
}
inflight, err := mul(w.Rate, w.MeanMS)
if err != nil {
return Result{}, err
}
conns, err := mul(w.MaxInstances, w.PoolPerInstance)
if err != nil {
return Result{}, err
}
budget := w.DBMax - w.DBReserve
return Result{float64(inflight) / 1000, float64(units) / (1024 * 1000), conns, budget, budget / w.PoolPerInstance}, nil
}
func readWorkload(path string) (Workload, error) {
var w Workload
f, err := os.Open(path)
if err != nil {
return w, err
}
defer f.Close()
d := json.NewDecoder(f)
d.DisallowUnknownFields()
if err = d.Decode(&w); err != nil {
return w, err
}
var extra any
if err = d.Decode(&extra); err != io.EOF {
return w, errors.New("expected exactly one JSON value")
}
return w, nil
}
// Ledger is an in-memory transaction model, not durable storage.
// One event ID identifies exactly one reservation payload.
type Event struct {
ID string
Units int
}
type Ledger struct {
mu sync.Mutex
stock int
done map[string]int
}
func NewLedger(stock int) *Ledger { return &Ledger{stock: stock, done: make(map[string]int)} }
func (l *Ledger) Reserve(e Event, failBeforeCommit bool) (bool, error) {
if e.ID == "" || e.Units <= 0 {
return false, errors.New("invalid event")
}
l.mu.Lock()
defer l.mu.Unlock()
if units, ok := l.done[e.ID]; ok {
if units != e.Units {
return false, errors.New("event ID payload conflict")
}
return false, nil
}
if l.stock < e.Units {
return false, errors.New("insufficient stock")
}
if failBeforeCommit {
return false, errors.New("injected before commit")
}
// The business change and deduplication mark share this critical section.
l.stock -= e.Units
l.done[e.ID] = e.Units
return true, nil
}
func (l *Ledger) Stock() int { l.mu.Lock(); defer l.mu.Unlock(); return l.stock }
// Deliberately wrong: the marker is committed before the business change.
// A simulated interruption leaves an event marked done without its effect.
func badMarkFirst(stock *int, done map[string]bool, e Event, interrupt bool) bool {
if done[e.ID] {
return false
}
done[e.ID] = true
if interrupt {
return false
}
*stock -= e.Units
return true
}
func demo() {
e := Event{"evt-42", 2}
l := NewLedger(10)
a, err := l.Reserve(e, false)
fmt.Printf("first: applied=%v stock=%d err=%v\n", a, l.Stock(), err)
a, err = l.Reserve(e, false)
fmt.Printf("redelivery: applied=%v stock=%d err=%v\n", a, l.Stock(), err)
_, err = l.Reserve(Event{"evt-42", 3}, false)
fmt.Printf("conflict: %v\n", err)
// Model: business stock survived elsewhere, but the worker lost dedup state.
restarted := NewLedger(l.Stock())
a, _ = restarted.Reserve(e, false)
fmt.Printf("new-worker-without-dedup: applied=%v stock=%d\n", a, restarted.Stock())
stock := 10
seen := make(map[string]bool)
badMarkFirst(&stock, seen, e, true)
a = badMarkFirst(&stock, seen, e, false)
fmt.Printf("bad-mark-first retry: applied=%v stock=%d\n", a, stock)
}
func run(args []string) int {
if len(args) != 1 {
fmt.Fprintln(os.Stderr, "usage: cloudmodels workload.json")
return 2
}
w, err := readWorkload(args[0])
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
r, err := Analyze(w)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
fmt.Printf("mean-in-flight=%.1f (not a peak bound)\n", r.MeanInFlight)
fmt.Printf("modeled-memory-GiB-seconds=%.0f (not a money quote)\n", r.GiBSeconds)
fmt.Printf("pool-envelope=%d budget=%d fit-instances=%d over-budget=%v\n", r.Connections, r.Budget, r.FitInstances, r.Connections > r.Budget)
demo()
return 0
}
func main() { os.Exit(run(os.Args[1:])) }main_test.go
代码块 · 115 行 · 点击收起 / 展开
go
package main
import (
"encoding/json"
"math"
"os"
"sync"
"sync/atomic"
"testing"
)
func sample() Workload { return Workload{1_000_000, 512, 200, 200, 200, 50, 5, 150, 30} }
func TestAnalyze(t *testing.T) {
got, err := Analyze(sample())
if err != nil || got.MeanInFlight != 40 || got.GiBSeconds != 100_000 || got.Connections != 250 || got.Budget != 120 || got.FitInstances != 24 {
t.Fatal(got, err)
}
w := sample()
w.MemoryMiB = 0
if _, err = Analyze(w); err == nil {
t.Fatal("accepted zero memory")
}
w = sample()
w.Requests = math.MaxInt64
if _, err = Analyze(w); err == nil {
t.Fatal("accepted overflow")
}
w = sample()
w.DBReserve = w.DBMax
if _, err = Analyze(w); err == nil {
t.Fatal("accepted no DB budget")
}
}
func TestConcurrentRedelivery(t *testing.T) {
l := NewLedger(10)
var applied atomic.Int64
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
ok, err := l.Reserve(Event{"x", 2}, false)
if err != nil {
t.Error(err)
}
if ok {
applied.Add(1)
}
})
}
wg.Wait()
if applied.Load() != 1 || l.Stock() != 8 {
t.Fatal(applied.Load(), l.Stock())
}
if _, err := l.Reserve(Event{"x", 3}, false); err == nil {
t.Fatal("payload conflict accepted")
}
}
func TestRetryBeforeCommit(t *testing.T) {
l := NewLedger(10)
if _, err := l.Reserve(Event{"x", 2}, true); err == nil {
t.Fatal("injection missing")
}
if ok, err := l.Reserve(Event{"x", 2}, false); !ok || err != nil || l.Stock() != 8 {
t.Fatal(ok, err, l.Stock())
}
}
func TestBadMarkFirst(t *testing.T) {
stock := 10
done := make(map[string]bool)
e := Event{"x", 2}
badMarkFirst(&stock, done, e, true)
if badMarkFirst(&stock, done, e, false) || stock != 10 {
t.Fatal("negative fixture changed")
}
// The test passes because it detects the deliberately lost reservation.
if !done[e.ID] {
t.Fatal("missing bad marker")
}
}
func TestInvalidEvent(t *testing.T) {
for _, e := range []Event{{"", 1}, {"x", 0}, {"x", -1}, {"x", 11}} {
l := NewLedger(10)
if _, err := l.Reserve(e, false); err == nil || l.Stock() != 10 {
t.Fatal(e, err)
}
}
}
func TestJSONInputs(t *testing.T) {
for _, data := range []string{`{}`, `{"unexpected":1}`, `{} {}`, `{`} {
p := t.TempDir() + "/w.json"
if err := os.WriteFile(p, []byte(data), 0600); err != nil {
t.Fatal(err)
}
w, err := readWorkload(p)
if err == nil {
_, err = Analyze(w)
}
if err == nil {
t.Fatalf("accepted %s", data)
}
}
}
func TestPolicyIsOnlyJSONFixture(t *testing.T) {
b, err := os.ReadFile("iam-read-example.json")
if err != nil {
t.Fatal(err)
}
var v map[string]any
if err = json.Unmarshal(b, &v); err != nil {
t.Fatal(err)
}
// Syntax check only. This does not evaluate AWS IAM effective permissions.
if v["Version"] != "2012-10-17" {
t.Fatal(v)
}
}workload.json
json
{
"requests": 1000000,
"memory_mib": 512,
"billed_ms": 200,
"requests_per_second": 200,
"mean_duration_ms": 200,
"max_instances": 50,
"pool_per_instance": 5,
"db_max_connections": 150,
"db_reserved_connections": 30
}fixtures/invalid.json
json
{
"requests": 1000000,
"memory_mib": 0,
"billed_ms": 200,
"requests_per_second": 200,
"mean_duration_ms": 200,
"max_instances": 50,
"pool_per_instance": 5,
"db_max_connections": 150,
"db_reserved_connections": 30
}iam-read-example.json
json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadReleaseObjectsOnly",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::example-demo-artifacts/releases/*"
}
]
}verify.sh
bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
go version
go test -count=1 ./...
go test -race -count=1 ./...
go vet ./...
go build -o "$tmp/cloudmodels" .
"$tmp/cloudmodels" workload.json
set +e
"$tmp/cloudmodels" fixtures/invalid.json >"$tmp/invalid.log" 2>&1
code=$?
set -e
cat "$tmp/invalid.log"
if [ "$code" -ne 2 ]; then echo "unexpected invalid-input exit=$code"; exit 1; fi
echo 'invalid-input: expected failure (exit=2)'
echo 'All local model checks passed; no cloud connection or IAM change.'本次实际输出
Go 单元测试和 race 测试通过,go vet 无输出且退出码为 0。执行程序与无效输入检查的输出如下:
text
mean-in-flight=40.0 (not a peak bound)
modeled-memory-GiB-seconds=100000 (not a money quote)
pool-envelope=250 budget=120 fit-instances=24 over-budget=true
first: applied=true stock=8 err=<nil>
redelivery: applied=false stock=8 err=<nil>
conflict: event ID payload conflict
new-worker-without-dedup: applied=true stock=6
bad-mark-first retry: applied=false stock=10
positive inputs required; DB reserve must be below DB max
invalid-input: expected failure (exit=2)
All local model checks passed; no cloud connection or IAM change.