百战商城商品数据这块,折腾了三个多月的云函数化改造,总算是收尾了。项目代号“百战_3”,单从名字看像是个普通技术迭代,其实它背后解决的问题很典型:电商系统的商品数据,承载着商品列表、详情、库存、价格、上下架状态这些核心信息,读多写少、高峰流量波动大,传统单体服务在这个场景下要么机器闲置浪费钱,要么活动流量一来就扩容告急。我们这次把商品数据的核心读写链路,整体迁移到了云函数即服务化架构上,用按量付费、自动伸缩的方式,把成本和运维压力都压下来了。这篇总结没有特别深奥的原理,更多是实操层面的取舍,适合正在做服务化拆分、或准备把商品这类读多写少业务往云函数迁的团队看一看,尤其建议先把里面踩过的几个坑记下来。
1. 改造背景与整体思路
1.1 我们为什么会动商品数据这块
老系统用了好几年,商品服务一直跑在固定几台云服务器上。平时流量还行,但每逢大促节点,商品详情页的QPS能冲到平时的几十倍。数据库压力是一方面,更头疼的是扩容流程太慢:先申请机器、装环境、配负载均衡,一套下来至少半小时,活动流量可不等你。
最难受的是成本问题。为了支撑高峰,服务器常年得按高峰规格预留,但平峰时CPU利用率可能连10%都不到。换句话说,大量的钱花在了“闲置算力”上。
我们一开始也考虑过容器化那一套,但衡量之后发现,商品数据这类接口有个天然特性:无状态、单次请求逻辑清晰、对启动时间不敏感。这类负载其实特别适合云函数这种事件驱动模型。按调用次数和时长付费,高峰时自动弹出几百个实例扛流量,低谷时缩到零个实例,成本模型完全是另一套逻辑。
当然,不是要把整个商品服务都推翻重来,那风险太大。我们的策略是“拆出关键链路”:把流量最高、逻辑最独立的商品详情、商品列表、库存查询这些接口迁到云函数,保留后台管理、商品发布这类低频但复杂的事务逻辑在原服务。这种渐进式改造,既控制了风险,又能快速看到效果。
1.2 改造范围与边界划定
动手之前,我们花了整整一周做接口盘点,把所有商品相关的接口按“调用频率、逻辑复杂度、对数据库的依赖方式”三个维度过了一遍。最终确定第一批改造名单:
| 接口 | 流量特征 | 逻辑复杂度 | 改造方式 |
|---|---|---|---|
| 商品详情查询 | 极高,读多写少 | 低,缓存为主 | 云函数重写 |
| 商品列表/搜索 | 高,参数组合多 | 中,需多级缓存 | 云函数重写 |
| 库存查询 | 极高,核心热数据 | 低,缓存+DB兜底 | 云函数重写 |
| 库存扣减 | 高,写操作,强一致 | 高,需防超卖 | 云函数+消息队列异步化 |
| 商品上下架 | 低,运营操作 | 中,状态流转校验 | 保留原服务,后续再迁 |
| 商品发布/编辑 | 低,涉及多表事务 | 高,强一致要求 | 保留原服务 |
这个表看着简单,但边界划定其实是个决策过程。我们的原则是:凡是能容忍最终一致性的读多写少接口,优先迁;凡是强事务、多表联动、必须实时一致的操作,留在原服务。云函数方不适合承载长事务,环境上是“跑完即走”的模型,如果硬塞一个多表join、长事务逻辑进去,不仅性能拉胯,排查问题也会变成地狱模式。
1.3 技术选型与总体架构
云函数平台我们选了市面上主流的Serverless服务,具体哪家不重要,核心是看三件事:触发器类型是否支持HTTP网关、实例并发策略是否可调、日志和监控是否完整。商品数据改造本质还是HTTP服务,网关触发是刚需。
整体架构变成了这样:
- 客户端请求打到API网关,网关负责域名、鉴权、限流;
- 网关将请求转发给对应的云函数实例,云函数内做参数校验、业务逻辑、数据组装;
- 数据层统一走Redis缓存,未命中时查数据库,但数据库连接不再直接由函数管理,而是经过一个中间层,避免高并发下把数据库连接池打爆;
- 库存扣减这类写操作,函数先操作Redis+Lua脚本做预扣减,再通过消息队列异步落库。
这套架构本质上没有创造新概念,就是把原来单体服务里的控制层逻辑挪到了函数里,只是每个函数只负责一个接口场景,生命周期变短,但并发能力大幅提高。
2. 商品数据链路重构
2.1 缓存策略与一致性保障
商品详情页数据90%以上是重复读的,改造前就有缓存。但原来缓存是和业务代码耦合在一起,同一个JVM里用本地缓存+Caffeine,再加上Redis,双级缓存逻辑复杂,缓存更新时经常出现数据不一致。
云函数化之后,函数实例是分布式且短命的,本地缓存基本没法用了。我们干脆把缓存全部收敛到Redis一层,Cache Aside模式,读请求先读Redis,命中直接返回;未命中则查数据库,回填缓存。写请求更新数据库后,删除缓存而不是更新缓存,避免并发写导致缓存与数据库不一致。
这里有一个容易被忽视的坑:删除缓存这种事,一定要在事务提交之后再删。如果先删缓存后写数据库,在写库期间来了读请求,会把旧数据重新加载进缓存,造成脏读。我们的做法是:数据库操作完成后,延迟100毫秒再删缓存,这个“延迟双删”策略实测下来,能有效规避多数并发不一致场景。
另外,缓存击穿问题也必须处理。活动期间某个爆款商品Key刚好过期,大量请求同时穿透到数据库,数据库直接就跪了。我们在函数内加了一个互斥锁(Redis SETNX),只有一个请求能去数据库加载,其他请求短暂阻塞后读取缓存。因为云函数实例数量很多,这种锁是跨实例的,所以必须用Redis实现,不能用本地锁。
2.2 数据库连接瓶颈与应对
这块是改造过程中最险的一个坑,差点让项目翻车。
云函数的特点是按请求拉起实例,高并发时可能同时存在几百个实例。每个函数实例如果都按传统方式创建数据库连接池,默认配置一个池子10-20个连接,那高峰期数据库要承受几千个连接。MySQL默认连接数上限也就几百到一千多,直接被击穿。
我们第一批压测时,商品详情接口在500并发下就把数据库连接打满了,报错信息清一色是“Too many connections”。
后面调整方略是:
- 函数内不建连接池,每次请求临时创建连接,用完即关。虽然每次连接的握手开销大,但云函数实例本身运行时间短,连接频率可控,配合后端的连接复用,实际开销可以接受。
- 数据库前面挂了一个连接代理层(类似DBProxy),统一管理到库的长连接;云函数只和代理创建短连接,代理帮我们做连接池复用。
- 设置合理的超时和重试:数据库操作超时改为2秒,重试一次。重试要小心幂等性,读操作没问题,写操作不能盲目重试。
最终压测下来,数据库连接数稳定控制在200以内,整个链路稳定多了。这里要特别提醒,改造成云函数后,原服务里那套连接池管理经验基本作废,必须从“函数生命周期短、实例数量动态变化”这个新视角重新设计。
2.3 库存扣减与超卖防护
库存是商品数据里唯一一个写多读少、必须强一致的点,也是我们最谨慎的地方。
最初的方案是函数直接UPDATE数据库扣库存,但并发压测时出了大问题:多个请求同时读到库存余量5,各自先判断再扣减,最后库存变成负数,超卖了。原因就是查和改之间不是原子的。
后来改成Redis + Lua脚本预扣减,这是业内比较成熟的方案。扣减时执行一个Lua脚本,原子性地完成“判断库存是否充足”和“扣减库存”两个动作,然后把扣减事件投递到消息队列,异步落库。数据库里的库存只作为最终对账依据,不再承担实时扣减。
Lua脚本的核心逻辑是:
local stock = redis.call('GET', KEYS[1]); if not stock or tonumber(stock) < tonumber(ARGV[1]) then return 0; end redis.call('DECRBY', KEYS[1], ARGV[1]); return 1;这里传的商品Key和扣减数量都通过参数传入,不能拼接字符串,防止脚本注入。Redis单线程执行Lua脚本,天然原子,不用加分布式锁。
异步落库时需要注意消息的幂等性:扣减消息可能被重复投递,消费端必须要有幂等校验,我们用的是“业务单号+操作类型”的组合去重。如果重复扣减,库存账就对不上了,这是上线后最容易被财务和数据团队找上门的bug。
3. 函数拆分与接口设计
3.1 函数拆分粒度
函数拆多大,直接决定了维护成本和运行效率。我们最开始走了弯路,试图把整个商品服务塞进一个“万能函数”,内部用路由区分接口。结果这个函数越来越大,依赖越来越多,每次发布都像在走钢丝。
后来换了思路,一个接口对应一个函数,函数名清晰,代码量控制在几百行以内,没有隐藏依赖。商品详情就是一个函数,库存扣减就是另一个函数。重复代码通过公共层(Layer)抽取,函数公共层就像传统的公共jar包,但它是独立部署、统一挂载到函数上的,更新公共层不用改动每个函数代码。
这种粒度还有一个好处:冷启动更快。函数包小,运行时加载的类少,实例拉起时间明显缩短。商品详情函数打包后不到10MB,从收到请求到实例响应,压测平均冷启动时间在700毫秒左右,可以接受。
3.2 冷启动优化实测
云函数圈里一聊就绕不开冷启动。首次请求时要拉取代码、初始化运行时,这段时间用户能感知到明显延迟。我们对冷启动做了三件事优化:
- 精简依赖包。原先商品服务依赖了很多工具类,其实就用到一小部分。我们逐一排查,去掉无用依赖,函数包体积直接下降60%。
- 开启平台提供的实例预热功能。核心函数配置了常驻实例池,平峰期保留2个预热实例,保证请求进来不用等冷启动。
- 代码内置初始化判断。数据库连接、Redis客户端这类重资源的初始化,用一个全局静态变量持有,只在实例创建时初始化一次,后续请求复用。
实测数据:商品详情函数冷启动率从改造初期的18%降到3%以内,98分位延迟从2.8秒降到700毫秒以内。对于电商详情页场景,这个延迟完全够用。
3.3 鉴权、超时与重试策略
原服务是传统的Spring拦截器鉴权,用户会话状态保存在服务端内存。云函数是分布式的,内存状态不可共享,鉴权逻辑必须重构。
我们统一改成JWT无状态鉴权:用户登录后签发Token,后续请求携带Token,函数内只验签和解析用户信息,不存任何会话。验签逻辑抽到公共层,所有函数共用一套代码。网关层做第一道校验,非法Token直接拦截,函数内再做第二道校验,双保险。
超时与重试这块,经验教训比较深刻。函数默认超时我们设为10秒,但实际压测发现,异常超时的请求往往都是数据库慢查询引起的。与其等超时,不如把数据库操作单独设置超时,2秒没返回就快速失败,返回给用户一个友好提示,而不是让请求悬挂在那里占用函数实例资源。
重试只针对幂等的读操作。商品查询挂了,重试一次很合理;但库存扣减、订单提交这类写操作,绝对不能让框架自动重试,否则重复扣款、超卖问题全出来了。这部分我们在网关层就配置了“POST请求不自动重试”的规则。
3.4 灰度发布:按Header路由
云函数平台支持版本和别名机制,这给了我们一个比较灵活的灰度发布手段。
我们的做法是:每个函数维护两个版本,稳定版本和候选版本。API网关层解析请求Header中的灰度标记,带有特殊标记的流量路由到候选版本,其余流量继续走稳定版本。观察一段时间没问题后,把全部流量切到新版本,再通过一键回滚脚本回到旧版本。
这个方案的好处是发布影响面可控。改动的函数有十几个,如果一次性全量发布,出了问题都不知道是哪个函数惹的祸。按接口维度灰度,一次只升一个函数,出现问题直接切回旧版本,排查范围非常小。
4. 全链路可观测与排查技巧实录
4.1 日志收集的重新设计
本地开发时打印日志很容易,但云函数是一堆短命实例,实例销毁后日志跟着没了。老办法完全不行。
我们搭了一套日志采集链路:函数通过标准输出打印结构化日志,日志服务自动采集,并按函数名、时间、TraceId建立索引。排查问题时,直接按TraceId搜索整个请求链路的日志,不用再一台台机器翻日志。
函数日志里一定要带的关键字段:
- TraceId:全链路唯一,网关生成透传,日志和数据库操作都要记录;
- 函数名和版本号:确认日志是哪个灰度环境产生的;
- 业务标识:商品ID、用户ID或订单号,方便按业务维度检索;
- 耗时分解:函数内部各段逻辑(参数校验、缓存读、数据库查、组装返回)分别计时,定位瓶颈一眼就能看出来。
我们刚开始把日志全打印在同一行,量大了之后查询效率很低。改成每段耗时单独一行日志,加上关键字标引,排查体验直线上升。
4.2 监控指标与告警设置
云函数平台的监控指标相对单薄,默认只有调用次数、平均耗时、错误次数。但对商品业务来说,这些还不够。我们额外做了两个维度的业务监控:
- 缓存命中率:低于某个阈值就说明缓存失效策略出了问题,需要告警;
- 库存预扣减与落库一致性:每5分钟对比一次Redis库存和数据库库存,差异超过阈值立即告警,这直接关系到超卖问题。
告警配置也有讲究。刚开始我们一个函数配置一套告警规则,十几个函数下来,告警风暴能把人烦死。后来收敛为分级告警:核心链路(详情、库存、下单)用短信加电话,次要接口用群消息通知,达到阈值后再升级。该给机器处理的不要浪费人的注意力。
4.3 常见问题排查实录
改造过程中积累了一批典型问题,整理成速查表,新成员接手时照着查就行:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 高峰时段接口大量超时 | 数据库连接被打满 | 看数据库连接数监控,确认是否超过上限;检查函数是否创建了连接池 |
| 商品详情偶发返回旧数据 | 缓存淘汰策略问题 | 看是否启用了多级缓存,本地缓存是否残留旧值 |
| 库存扣减成功但订单失败 | 预扣减与异步落库不一致 | 查消息队列消费日志,确认落库是否成功,是否有重试导致的重复扣减 |
| 函数冷启动率突然升高 | 代码或依赖变更导致包变大 | 对比函数包体积,检查是否误加了不必要的依赖 |
| 灰度版本流量异常 | 网关路由规则配置错误 | 检查Header路由规则,确认流量标记字段是否拼写一致 |
这个表格里的问题,大部分我们真的踩到过。尤其是“函数冷启动率升高”这个,有次更新公共层时误把一个大型JSON解析库带了进去,包体积直接翻倍,冷启动率飙到30%,排查了一天才定位到是这个原因。公共层的依赖审查,一定不能走捷径。
5. 成本、性能与收益复盘
5.1 成本模型对比
成本这块是老板最关心的。改造前商品服务占用了4台8核16G的云服务器,按年费算,加上负载均衡、带宽、数据库实例,一年成本大概在某个六位数区间,但这其中平峰期的算力大量闲置。
云函数按调用次数加运行时长计费,商品数据接口日均调用量在百万级,平均单次执行时长是120毫秒。我们按峰值并发配置了预置实例,但大部分时间实例数是零到个位数。核算下来,月成本仅为原来的40%左右,高峰期因为临时扩容产生的费用占比也很小。
当然,云函数的单价虽然便宜,但用久了会有“账单切碎”的问题:每个函数单独出账,十几个函数的账单汇总起来,看不太清楚。我们后来把函数按业务模块分组,设置预算阈值和异常波动告警,费用一目了然。
5.2 性能提升数据
改造后的性能提升比较明显,这里列几组压测对比数据:
| 指标 | 改造前(服务器) | 改造后(云函数) |
|---|---|---|
| 单接口最大并发 | 800 | 3000+ |
| 高峰扩容时长 | 30分钟以上 | 自动秒级伸缩 |
| 商品详情P99延迟 | 1.2秒 | 350毫秒 |
| 库存扣减成功率 | 99.2% | 99.95% |
| 数据库峰值连接数 | 800+ | 200以内 |
延迟降低的原因,第一是缓存层更简单直接,没有多级缓存互相干扰;第二是函数实例更轻量,单次请求执行路径更短;第三是数据库连接压力小了,慢查询少了。
不过也要说句公道话,云函数不适合所有场景。我们试过把后台商品批量导入导出功能也函数化,结果因为涉及大量数据在短时间内密集写库,单函数超时和资源限制问题频繁出现,后面还是回迁到了原服务。业务场景和架构模型不合适,不用硬融合。
5.3 项目后续还能怎么扩展
这次改造跑通之后,我们已经在规划第二期的事情。
商品数据只是开了个头,接下来会把订单查询、购物车这类“无状态、读多写少”的接口也逐步迁到云函数,并沉淀一套内部函数脚手架,新接口直接按模板开发,不需要每次从零搭环境。
还有个方向是事件驱动化:商品数据变更后向消息队列发事件,库存扣减、价格同步、搜索索引更新都通过订阅事件异步执行,进一步解耦。这个改造暂时只在规划中,但有了这期的基础,实现起来会顺很多。
希望这篇总结能帮到同样在折腾服务化改造的团队。最后说点掏心窝的话:云函数化不是银弹,它解决了弹性扩容和成本问题,但带来了调试难、冷启动、连接管理复杂等新麻烦。如果你打算启动类似改造,一定要在动手前把改造范围和接口边界想清楚,过程中把监控和日志体系前置建立起来,这两件事做好了,后面基本不会出大乱子。