不少朋友第一次上手AWS,就会撞上Lambda和DynamoDB这对组合。原因很简单:它们俩一个管计算、一个管存储,都是Serverless阵营里的主力,搭配起来几乎不用管服务器,写完代码传上去就能跑。我自己在几个模拟项目和跨平台Demo里反复用这对组合,最大的感受是——入门是真的快,但一旦流量上来、业务复杂起来,坑也是一个接一个。这篇文章不打算讲教科书式的理论,我直接把从选型、建表、写CRUD、接Streams,到生产环境踩坑、性能调优、账单优化的完整过程摊开来讲。无论你是刚接触Serverless的新手,还是已经被DynamoDB的Limit困惑过的老手,看完应该都能少走几段弯路。
1. 为什么偏偏是Lambda + DynamoDB:从一次选型说起
1.1 先搞清楚这对组合解决什么问题
先说清楚Lambda和DynamoDB各自负责哪一块。Lambda是无服务器函数计算,你只管写函数逻辑,AWS负责底层容器的拉起、扩容、负载均衡,用多少算多少。DynamoDB则是全托管的NoSQL键值数据库,读写延迟通常在个位数毫秒级别,而且天然支持自动分区扩展。
这两个服务合在一起,最典型的用法就是"事件驱动":
客户端请求 -> API网关 -> Lambda -> DynamoDB ^ | 同时可触发 DynamoDB Streams -> 另一个LambdaLambda负责业务逻辑编排,DynamoDB负责状态存储,两者通过SDK通信或者通过Streams解耦。这个模式的好处是:没有EC2要维护,没有连接池要调优,没有半夜扩容的告警电话。你做一次Demo项目从零到上线,可能只需要一条sam deploy命令。
1.2 和传统架构相比,省掉的到底是什么
传统上,一台应用服务器要面对数据库连接数、线程池大小、内存堆栈一堆问题。举个例子,你用Spring Boot连MySQL,连接池默认20个,突然来200个并发,数据库连接直接被占满,应用就卡死。而Lambda每次调用都是一个全新的执行环境,不需要自建连接池,DynamoDB也天然吃高并发——这个组合把"并发连接管理"这个经典难题直接消灭了。
不过代价也有:你不能像用SQL那样随心所欲地查询。DynamoDB只支持主键查询、索引查询、Scan(通常不推荐),没有JOIN,没有聚合函数。这意味着什么?意味着表结构设计几乎决定了你后面所有代码的写法——我在项目里见过太多人上来就用关系型数据库的思路建模,结果写一半发现查不出来,只能推翻重来。
1.3 什么场景适合、什么场景别碰
以我的实测经验,下面这些场景特别适合Lambda + DynamoDB:
- 轻量级API:用户信息、设备状态、订单记录等简单存取。
- 流式数据处理:Kinesis或DynamoDB Streams触发实时聚合。
- 定时任务:定时扫描、清理过期数据、生成报表。
- 物联网设备上报:设备数量大、单条数据小、写多读少的场景。
- 临时验证型功能:灰度开关、A/B实验配置、一次性导入导出。
不适合的场景也很明确:需要复杂事务跨多张表(DynamoDB虽然有TransactWriteItems,但限制很多)、需要报表级聚合查询、数据量不大但查询模式极其多样——这些情况老老实实用关系型数据库反而更省事。另外,对延迟极其敏感、需要常驻内存状态的业务(如实时排名、在线游戏会话),也不适合用Lambda这种按调用计费的模型。
2. 开工前必须想清楚的三件事:权限、表设计和容量成本
2.1 IAM角色:最小权限原则的落地姿势
很多新手(包括我早期)遇到权限不足,第一反应就是给Lambda挂一个AdministratorAccess。这在本地Demo跑没问题,但在生产环境是绝对的雷。IAM角色的正确姿势是:只给Lambda它真正需要的权限。
一个写DynamoDB的Lambda函数,典型的IAM策略应该长这样:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:Query" ], "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/orders" }, { "Effect": "Allow", "Action": [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": "*" } ] }这后面还有个容易忽略的点:如果表上有GSI(全局二级索引),而你的Lambda要直接Query这个索引,权限里的Resource就要同时包括arn:aws:...:table/orders/index/your-gsi-name,否则会在CloudWatch日志里看到AccessDeniedException,排查半天都不知道问题出在索引权限上。我踩过这个坑,教训就是——权限的最小化,不等于权限配置的简单化,Resource列表要列全。
2.2 表设计:主键和GSI的思路要反过来想
DynamoDB的设计哲学是"从查询倒推表结构"。你问的不是"用户有哪些字段",而是"我的业务会怎么查这条数据"。
以订单表为例子:
- 分区键(Partition Key):
user_id,因为最常见查询是"某个用户的所有订单"。 - 排序键(Sort Key):
order_id(用UUID或时间戳),用来在同一用户下排序和范围查询。 - 如果需要"按时间范围查某个用户的订单",排序键用
created_at更直接。
最经典的坑是:一开始把订单ID设为分区键,等业务要做"查用户订单列表"时,发现只能Scan全表,费用直线上升。这时候再改表结构,就得做数据迁移,麻烦不是一点半点。我的建议是开工前用一页纸把所有查询场景列出来:按什么查、返回哪些字段、多大数据量,然后据此设计主键和GSI。
GSI的设计也一样:一个GSI的本质是"复制一份数据,用另一个主键排列"。它是有成本的(存储和读写都要额外算钱),所以GSI的数量控制得越少越好。能用主键解决的,绝对不要上GSI。
2.3 容量模式:按需还是预置,这笔账要算清楚
DynamoDB有两种容量模式,很多人在这一步掉坑:
- 按需模式(On-demand):不用配置读写容量,按实际消耗计费,单价更高,适合流量波动大或不确定的场景。
- 预置模式(Provisioned):配置读写容量单位(RCU/WCU),便宜,但一旦超过配置值,直接被Throttle(限制)掉,需要做Auto Scaling或预留余量。
我的判断标准很简单:业务刚起步、流量不明,先用按需;流量稳定后,如果月账单里DynamoDB费用占比明显偏高,再评估预置+Auto Scaling。
预置模式要注意的是RCU/WCU的计算规则:
- 1个RCU = 每秒1次强一致读(最多4KB)或2次最终一致读(最多4KB)。
- 1个WCU = 每秒1次写入(最多1KB)。
举个例子,单条记录2KB,读取用默认的最终一致,那么每秒100次读取就需要 100 × (2KB / 4KB) / 2 = 25 RCU。写入每秒50条2KB记录,则需要 50 × 2 = 100 WCU。这个算完,你就知道预置该设多少了。算错的话,不是多花钱,就是被限流,两条路都不好走。
3. 一个订单服务的完整实战:从CRUD到Streams联动
3.1 项目结构与依赖的搭建思路
我用Python(boto3)写Lambda比较多,主要因为上手快、依赖少。一个标准的Lambda项目,我会这样组织:
order-service/ ├── template.yaml # SAM模板,定义Lambda、表、权限 ├── src/ │ ├── app.py # 入口,处理API网关事件 │ ├── orders.py # 订单CRUD逻辑 │ └── models.py # 数据模型与校验 ├── tests/ │ └── test_orders.py └── events/ └── api_event.json # 本地调试用的模拟事件用SAM(Serverless Application Model)而不是手动在控制台点来点去,是因为SAM可以用一个YAML文件同时声明Lambda、DynamoDB表、IAM角色,部署一条命令搞定。实测下来,SAM在本地调试和CI/CD集成上比手动配置稳得多。
3.2 CRUD核心代码:直接抄作业的版本
下面是我在订单服务里实际用过的核心代码,加了注释,照着就能跑。先看创建订单:
import json import uuid import time import boto3 from botocore.exceptions import ClientError dynamodb = boto3.resource('dynamodb', region_name='us-east-1') TABLE_NAME = 'orders' table = dynamodb.Table(TABLE_NAME) def create_order(event, context): body = json.loads(event.get('body', '{}')) required_fields = ['user_id', 'amount', 'items'] # 字段校验:缺失直接返回400,避免脏数据进来 for field in required_fields: if field not in body: return _response(400, {'error': f'missing field: {field}'}) order_id = str(uuid.uuid4()) created_at = int(time.time()) item = { 'user_id': body['user_id'], 'order_id': order_id, 'amount': body['amount'], 'items': body['items'], 'status': 'PENDING', 'created_at': created_at, 'updated_at': created_at } try: # 用ConditionExpression防止重复创建(幂等性第一道防线) table.put_item( Item=item, ConditionExpression='attribute_not_exists(order_id)' ) return _response(200, {'order_id': order_id, 'status': 'PENDING'}) except ClientError as e: if e.response['Error']['Code'] == 'ConditionalCheckFailedException': return _response(409, {'error': 'order already exists'}) return _response(500, {'error': str(e)})查询订单列表,核心是Query而不是Scan:
def list_orders(event, context): user_id = event.get('pathParameters', {}).get('user_id') if not user_id: return _response(400, {'error': 'pathParameters user_id required'}) try: result = table.query( KeyConditionExpression='user_id = :uid', ExpressionAttributeValues={':uid': user_id}, ScanIndexForward=False, # 按时间倒序,最新订单在前 Limit=20 ) return _response(200, {'orders': result.get('Items', [])}) except ClientError as e: return _response(500, {'error': str(e)})这里有个关键点:ScanIndexForward=False配合Limit=20,是"查最近20条订单"的标准姿势。这也是为什么我强烈建议排序键用时间字段——如果没有排序键,你只能查全表再自己排序,性能和费用都难看。
3.3 DynamoDB Streams:让数据自己"流动"起来
Streams是Lambda + DynamoDB组合里最被低估的能力。简单理解:你对表里每条数据的增删改,都会被记录到Stream里,你可以绑一个Lambda去响应这些变更。
我最常用的场景是:订单表发生状态变更后,自动把变更同步到另一个"订单汇总表",或者触发通知逻辑。注意这里不应该在业务Lambda里直接调用另一个函数——那会增加耦合,而且会拉长同步链路。用Streams解耦才是标准姿势。
# template.yaml 中绑定Streams触发器的核心片段 OrderStatusSyncFunction: Type: AWS::Serverless::Function Properties: CodeUri: src/sync/ Handler: app.lambda_handler Events: OrderStream: Type: DynamoDB Properties: Stream: !GetAtt OrdersTable.StreamArn StartingPosition: TRIM_HORIZON BatchSize: 10 MaximumRetryAttempts: 3Stream事件里有个容易被忽略的细节:NEW_AND_OLD_IMAGES。在创建表时,Stream视图类型要选对:
KEYS_ONLY:只记录主键,适合同步删除操作。NEW_IMAGE:记录变更后的数据,适合更新汇总。OLD_IMAGE:记录变更前的数据,适合审计回滚。NEW_AND_OLD_IMAGES:前后都有,信息最全,但存储和计算开销最大。
我的默认选择是NEW_AND_OLD_IMAGES,因为排查问题时有前后对比,能少很多猜谜时间。上线一段时间后,如果确认用不到旧值,再降级到NEW_IMAGE。
3.4 小技巧:用流处理器做幂等去重
Streams触发有"至少一次"语义,也就是说同一个事件在极端情况下可能被处理两次。如果你在流处理器里做的是"累加计数"这类操作,就得自己带幂等保护。
我的做法是在记录里加一个处理标记:
def lambda_handler(event, context): for record in event['Records']: if record['eventName'] not in ('INSERT', 'MODIFY'): continue new_image = record['dynamodb']['NewImage'] order_id = new_image['order_id']['S'] processed_key = { 'pk': f'PROCESSED#{order_id}', 'sk': f'EVENT#{record["dynamodb"]["SequenceNumber"]}' } try: # 条件写入,若已经处理过则跳过 processed_table.put_item( Item=processed_key, ConditionExpression='attribute_not_exists(pk)' ) # 真正的业务逻辑... except ClientError as e: if e.response['Error']['Code'] == 'ConditionalCheckFailedException': continue raise这个模式看着绕,但能完美解决"重复处理导致数据翻倍"的问题。我在一个日志聚合项目里这么干过,线上跑了几个月没出过一次重复计数。
4. 生产环境最容易翻车的几个坑:冷启动、节流与重试风暴
4.1 冷启动的真实影响与缓解手段
Lambda冷启动,就是函数第一次被调用时,需要新拉容器、装运行时、跑初始化代码,这期间请求会被挂起。Python函数的冷启动时间通常在几百毫秒到一秒之间,如果是Java或.NET会更高。
冷启动对用户体验的影响取决于你的业务:内部后台任务无所谓,但对外API就敏感了。缓解手段我实测有效的是这几招:
- 把只读的全局变量(数据库连接、HTTP客户端、配置)放在
handler函数外面初始化,让它跟随执行环境复用。 - 生产环境为热点函数开启Provisioned Concurrency(预置并发),提前准备好几个热执行环境,代价是持续计费。
- 如果可以,把运行时从Python换成Node.js或Go,冷启动时间明显更短。
有个细节值得注意:AWS_SDK的初始化本身就是耗时大户。我以前习惯在文件顶部import boto3后立即创建dynamodb = boto3.resource(...),冷启动会多出几百毫秒。改进做法是在handler里用全局变量做懒加载:
dynamodb = None def get_table(): global dynamodb if dynamodb is None: dynamodb = boto3.resource('dynamodb', region_name='us-east-1') return dynamodb.Table('orders')4.2 DynamoDB节流和Lambda重试形成的双重压力
这是生产环境最隐蔽的坑。场景是这样的:有个函数每秒写100条数据到DynamoDB,如果预置容量只有50 WCU,那么超过50的写入会被拒绝(Throttle)。Lambda SDK会自动重试被节流的请求,而Lambda本身对DynamoDB事件源还有内部重试机制。两边一起重试,结果就是DynamoDB持续满载,Lambda持续重试,Error率居高不下,账单还特别难看。
排查这类问题,我一般分两步:
第一步,看CloudWatch指标。先看DynamoDB表的ThrottledRequests和WriteThrottleEvents,再对比Lambda的ErrorCount和Throttles。如果两个指标同时飙升,基本就是容量不足加雪崩重试。
第二步,对症下药,通常从这三选一入手:
- 提高预置容量或切换按需模式,消除节流根源。
- 给DynamoDB的客户端配置更保守的重试策略(SDK默认是重试很多次的)。
- 关键批次改用队列缓冲:Lambda先写入SQS,再由SQS触发另一个Lambda批量写DynamoDB,把突发流量削峰填谷。
4.3 "至少一次"语义下的幂等设计
Lambda使用者必须接受一个事实:你的函数可能被重复调用,而且可能以乱序调用。DynamoDB Streams的事件尤其如此——Lambda重试时,事件顺序可能与原始顺序不同。
我处理这类问题的通用方案很简单:每次写库都带上一个时间戳排序键,更新采用条件写入或"只有新值>旧值才覆盖"的规则。
例如更新订单状态:
def update_order_status(user_id, order_id, new_status, event_time): key = {'user_id': user_id, 'order_id': order_id} try: table.update_item( Key=key, UpdateExpression='SET #s = :s, updated_at = :t', ConditionExpression='attribute_not_exists(updated_at) OR updated_at < :t', ExpressionAttributeNames={'#s': 'status'}, ExpressionAttributeValues={ ':s': new_status, ':t': event_time } ) except ClientError as e: if e.response['Error']['Code'] == 'ConditionalCheckFailedException': # 旧事件到达,直接忽略 pass这行代码看起来不起眼,但它在高并发场景下能挡住无数脏数据。没有这个保护,两个并发请求先后更新同一订单,后到的旧状态反而盖掉新状态,线上事故就是这么来的。
4.4 死信队列:别让失败事件悄悄消失
另一个生产必做项:给Streams触发器设置DLQ(死信队列)。当一次事件重试多次仍然失败,它会被投递到指定的SQS队列里,这样你可以事后分析、手动重放。
在SAM模板里加一段就行:
OrderStatusSyncFunction: Properties: EventInvokeConfig: DestinationConfig: OnFailure: Type: SQS Destination: !GetAtt DeadLetterQueue.Arn Policies: - SQSSendMessagePolicy: QueueName: !Ref DeadLetterQueue没有这个DLQ,失败事件被丢弃后你是完全无感知的,等你发现订单状态没同步时,数据早就对不上了。我在一个模拟项目中第一次上线时没配DLQ,半个月后发现用户退款状态大面积缺失,只能从DynamoDB Streams里手动捞历史记录补跑,教训深刻。
5. 性能与成本调优:我实测后得出结论的几个方向
5.1 尽量用批量API,别一条一条写
DynamoDB的BatchWriteItem一次最多写入25条记录,BatchGetItem一次最多读取100条。跟逐条PutItem相比,批量API不仅能减少网络往返,还能显著降低成本。
我之前做数据导入时,一开始逐条写入,100万条数据要写几十个小时。后来改成批量写入,每个批次25条,并行20个线程,速度提升了近10倍,WCU消耗反而下降了一半——因为批量写入同样产生一次请求开销,但分摊到25条记录上,整体容量消耗更可控。
批量写入代码示例如下:
def batch_write_items(items, table_name): dynamodb = boto3.client('dynamodb', region_name='us-east-1') for i in range(0, len(items), 25): batch = items[i:i+25] request_items = { table_name: [ {'PutRequest': {'Item': item}} for item in batch ] } try: response = dynamodb.batch_write_item(RequestItems=request_items) # 关键:检查未处理的项 unprocessed = response.get('UnprocessedItems', {}) if unprocessed: # 实现退避重试 time.sleep(0.5) dynamodb.batch_write_item(RequestItems=unprocessed) except ClientError as e: # 节流时指数退避 if e.response['Error']['Code'] == 'ProvisionedThroughputExceededException': time.sleep(1)注意UnprocessedItems这个返回值:批量写入如果超过容量阈值,部分未写入的项会留在响应里,你必须对这部分做重试,否则数据就丢了。很多人的批量写入看似成功,实际上丢了几条而不自知,就是因为没处理这个字段。
5.2 预留并发和表容量要一起调
Lambda的默认并发限制是某个账户级别的总并发数(默认1000)。如果同时上线多个Lambda,高峰期可能互相抢资源,导致别的函数被节流。生产环境需要为关键路径上的函数设置预留并发(Reserved Concurrency),保证它随时有额度可用。
但预留并发不是越大越好:预留并发意味着这些执行环境是常驻等待的,要花钱。它和DynamoDB的预置容量一样,讲究的是"够用就好"。
从账单反向优化是最实用的方法。我一般看这三项:
- CloudWatch的
Duration:如果平均执行时间在几十秒,可能需要优化代码而不是加并发。 - DynamoDB的
ConsumedReadCapacityUnits和ProvisionedThroughputExceededException:前者告诉你实际消耗,后者告诉你是否在流泪限流。 - Lambda的
Invocations:看哪个函数最贵,优先优化它。
5.3 用TTL省存储费:一个几乎没人提的省钱技巧
DynamoDB支持在表上启用TTL(生存时间)。设置TTL字段后,数据过期后会被后台自动删除,没有额外费用。我在日志类表里用这个特性,让30天前的日志自动清理,省下的存储费用相当可观。
# 写入时带上TTL时间戳(单位:秒) item['ttl'] = int(time.time()) + 30 * 24 * 3600 # 30天后过期用TTL有个细节:过期数据不是立刻删除的,后台扫描删除通常有一点延迟,所以如果你的业务逻辑不依赖TTL的即时性,就没什么问题。需要精确控制数据生命周期时,还是得用定时Lambda或Streams清理。
6. 本地调试与生产观测:我的日常工作流
6.1 用SAM和DynamoDB Local把调试成本降到最低
Lambda的调试比普通后端麻烦,原因是它运行在AWS的托管环境里。实测最有效率的方式是本地起一个DynamoDB Local(Docker镜像),配合sam local invoke,不用每次改代码就部署到云端。
# 启动本地DynamoDB docker run -p 8000:8000 amazon/dynamodb-local # 用SAM本地调用函数,传入模拟API网关事件 sam local invoke OrderFunction -e events/api_event.json # 本地创建测试表 aws dynamodb create-table \ --endpoint-url http://localhost:8000 \ --table-name orders \ --attribute-definitions AttributeName=user_id,AttributeType=S AttributeName=order_id,AttributeType=S \ --key-schema AttributeName=user_id,KeyType=HASH AttributeName=order_id,KeyType=RANGE \ --billing-mode PAY_PER_REQUEST本地调试最大的价值,是你能在几秒内复现"表不存在""字段类型不匹配""条件写入冲突"这些高频问题,而不是每次部署到云端等一两分钟才能看到结果。我在模拟项目里90%的DynamoDB报错都是在本地发现并解决的,真正上云后反而稳得很。
6.2 用CloudWatch和X-Ray定位慢请求
线上出问题时,第一步永远是看日志:aws logs tail /aws/lambda/order-fn --follow。但日志只能告诉你哪一行报错,要定位性能瓶颈,还得靠X-Ray。
给Lambda按如下方式开启X-Ray追踪:
Globals: Function: Tracing: Active开启后,X-Ray会显示一次调用里每个子请求的耗时分布:DynamoDB占了多少、API网关占了多少、自身代码执行占了多少。我在优化一个慢接口时,X-Ray清楚显示90%的时间花在DynamoDB的Query上,而不是我猜的冷启动——这也提醒我,凭直觉优化是最贵的优化方式,没有观测数据就别动刀。
6.3 预警和告警:守卫最后一道防线
别忘了给关键指标配CloudWatch告警,我推荐的三个必配项:
- Lambda
ErrorCount> 0:立即通知。 - DynamoDB
ThrottledRequests> 0:表示容量吃紧。 - Lambda
Duration超过预设阈值:可能存在代码性能问题。
告警的目的不是事后响应,而是让你在问题影响用户之前就能介入。我在生产环境里被大量教训教育过:没有告警的系统,就是在裸奔。
结尾:一段来自实战的经验沉淀
做了这么多Lambda + DynamoDB的项目,我自己最深的体会是:这对组合很适合"以最小成本快速验证业务逻辑"——它帮你把基础设施的复杂度封装掉了,但对应的,你必须在架构设计、数据建模、幂等和重试方面花更多心思。这些心思前期看不出来,流量翻倍的时候全都会变成你的底气。最后给一个实在的建议:任何一个新上线的Lambda + DynamoDB项目,开工前花半小时把数据流图画出来,标清楚哪些地方可能重试、哪些地方可能丢失数据,再决定用ConditionExpression和DLQ兜底,这半小时的性价比远超你后面任何一次加班排查。