别把业务逻辑都堆进云函数:微信小程序云开发的分层架构与成本实测
一个做社区团购的小程序,上线第三个月云函数只有 6 个。半年后涨到 41 个,其中 11 个超过 800 行,orderSubmit 一个函数里同时干了库存扣减、优惠券核销、积分计算、订阅消息推送和退款预处理。改一行优惠规则要回归整条下单链路,谁都不愿意接手。
冷启动也被拖垮了。这个函数部署包 6.2MB,因为把 lodash、dayjs、一个 PDF 生成库全打进去了,用户点「提交订单」平均要等 1.8 秒才有响应,其中 1.2 秒花在容器拉起和依赖初始化上。
这篇记录的是我们怎么把这种巨型云函数拆成三层,以及拆完之后云开发(CloudBase)的资源用量和账单数字具体变了什么。
适用读者:已经用云开发上线过小程序、正被云函数体积和调用链路困扰的前端或全栈开发者。文中示例基于 Node.js 16 运行时与 wx-server-sdk 2.x,需要你了解云数据库集合、权限配置和定时触发器的基本概念。
巨型云函数的三个典型症状
函数变胖不是一天的事,等到出问题往往已经很难回头。我们复盘了那 41 个函数,找出三个反复出现的症状。

第一个症状是发布恐惧。 因为所有规则都在一个包里,改积分比例要重新部署整个下单入口,测试同学得把下单、退款、核销三条路径全跑一遍。第 14 周那次上线,就因为顺手改了一行日志格式,回归漏测导致优惠券被重复核销,赔付了 2300 多块。
第二个症状是权限失控。 前端同学为了省事,直接在页面里 wx.cloud.callFunction 传一个 action 字符串,云函数里用 switch 分发到不同逻辑。这样做的结果是权限判断散落在函数内部,有的分支校验了 openid,有的分支忘了。我们做过一次排查,action 一共 23 个分支,其中 7 个没有校验用户身份,任何一个登录用户都能调用退款预处理。
第三个症状是成本看不见。 所有逻辑共用一个函数,控制台只能看到这个函数的总调用次数和总耗时,分不清到底是库存查询还是消息推送在烧钱。月底看到 GBs 超了,也不知道该优化哪一段。
把
action当路由用的写法,等于把 API 网关的责任塞进了函数体。云函数本身应该只对应一个明确的业务动作。
分层怎么切:云函数只当 BFF
我们最后定的结构是三层,从上到下是:接入层(Backend For Frontend, BFF)、领域服务层、数据访问层。
接入层就是云函数本体,只干四件事:鉴权、参数校验、调用领域服务、组装返回结构。它不直接碰数据库集合,也不写业务规则。领域服务层是普通的 Node.js 模块,放在云函数目录的 service/ 下,库存、优惠、积分各自一个文件。数据访问层统一封装集合操作,把 db.collection() 的调用收口到一个地方,方便加缓存和埋点。
这么切的好处不是架构好看,而是成本核算能落到具体动作上,权限校验也只有一处入口。
graph TD
A[小程序页面 wx.cloud.callFunction] --> B[接入层 云函数 orderSubmit]
B --> C[鉴权与参数校验 middleware/auth.js]
C --> D[领域服务 service/stock.js]
C --> E[领域服务 service/coupon.js]
C --> F[领域服务 service/point.js]
D --> G[数据访问 repo/orderRepo.js]
E --> G
F --> G
G --> H[(云数据库集合)]
D --> I[(云数据库集合)]
F --> J[订阅消息推送]
改造前后,同一个「提交订单」动作的差异大致是这样:
| 对比项 | 改造前 | 改造后 |
|---|---|---|
| 云函数部署包大小 | 6.2MB | 1.1MB |
| 冷启动 P95 耗时 | 1180ms | 320ms |
| 单次调用内存规格 | 256MB | 128MB |
| 权限校验位置 | 函数内 23 个分支分散判断 | 接入层统一中间件,1 处 |
| 改动优惠规则的回归范围 | 下单 + 退款 + 核销全链路 | 仅 coupon 服务单测 |
| 新增一个业务动作的成本 | 在 switch 里加 case,全量发布 | 新增服务文件,发布单个函数 |
冷启动的机制:一次调用到底发生了什么
要理解为什么拆包能省这么多,得先看清一次调用在函数计算平台上经历了什么。云开发的云函数跑在腾讯云的函数计算环境里,一次「冷」调用按下面的顺序走。
平台先决定要不要新开实例。如果此刻有空闲且代码版本一致的实例,直接复用,这就是热调用,通常几十毫秒内返回。没有可复用的实例时,平台要分配一台容器、从对象存储拉取代码包、解压、启动 Node.js 进程、执行模块顶层代码、最后才跑你的 exports.main。
这里面有两段时间和你的代码写法直接相关。拉取并解压代码包的时间和包体积正相关,6.2MB 和 1.1MB 差出来的几百毫秒基本来自这里。模块顶层代码的执行时间则取决于你在文件顶部做了多少事,比如在全局作用域里 require 一堆只在某个分支才用到的库,或者在顶层直接建立数据库连接。
还有一个容易忽略的点:cloud.init() 和 cloud.DYNAMIC_CURRENT_ENV 的用法。如果在每个服务文件里都各自初始化一次 SDK,这部分开销会成倍放大。正确写法是在入口初始化一次,把 db 实例通过参数或模块单例传下去。
// 云函数 orderSubmit/index.js
// 依赖:wx-server-sdk 2.x,Node.js 16 运行时
const cloud = require('wx-server-sdk')
// 在模块顶层只初始化一次,不要在 service 里重复 init
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
// 全局单例,实例复用期间不会重复创建
const db = cloud.database()
const auth = require('./middleware/auth')
// 领域服务,各自独立文件
const stock = require('./service/stock')
const coupon = require('./service/coupon')
exports.main = async (event, context) => {
// 接入层第一件事:鉴权,拿不到身份直接拒绝
const { openid } = await auth.resolve(event)
// 只做结构校验,业务规则不写在这里
const { skuId, count, couponId } = event
if (!skuId || count <= 0) {
return { code: 400, msg: '参数不合法' }
}
// 领域服务各自管自己的事务边界
const locked = await stock.lock(skuId, count)
// 优惠券核销与库存锁定必须在同一函数内,才能共用事务
const discount = await coupon.verify(openid, couponId, skuId)
// 组装返回,前端不关心内部调用过哪些服务
return { code: 0, data: { locked, discount } }
}
实例在一段时间没有请求后会被回收,回收之后下一次调用又变成冷启动。所以「降低包体积」和「减少顶层开销」这两件事对高频函数意义更大,低频函数本来每次都冷,优化收益反而有限。
数据库权限模型别指望云函数兜底
分层之后我们把权限收口到接入层,但这还不够。云数据库的权限配置是第二道防线,很多人从来没打开过。
集合的权限有四种:所有用户可读、仅创建者可读写、所有用户可读写、仅管理端可读写。客户端直连数据库时(也就是小程序里直接 db.collection().get()),只有这套权限会生效;走云函数访问时,函数运行在管理端环境,权限配置对它不生效。
所以判断标准很简单:如果这个集合需要被客户端直连读,就必须配置权限;如果只允许云函数访问,就设成仅管理端可读写,并且不要在任何云函数里把 db 的查询结果原样透传给前端未校验的用户。
| 集合 | 访问方式 | 权限设置 | 说明 |
|---|---|---|---|
goods |
客户端直连读 | 所有用户可读,仅管理端可写 | 商品信息公开,写入必须走云函数 |
orders |
仅云函数 | 仅管理端可读写 | 含价格与地址,不放开客户端 |
user_coupon |
客户端直连读 | 仅创建者可读写 | 靠 _openid 自动隔离 |
ops_log |
仅云函数 | 仅管理端可读写 | 运营日志,客户端无感知 |
只靠简单权限还不够表达「已支付的订单不能改」这类规则时,可以写数据库安全规则。它是一段 JSON,在集合级别生效,能对读和写分别写条件表达式。
// 集合 orders 的安全规则片段
// 依赖:云开发控制台 数据库 -> 集合 -> 权限设置 -> 自定义安全规则
{
"read": "doc._openid == auth.openid",
"write": "doc.status == 'unpaid' && doc._openid == auth.openid"
}
这段规则的意思是:只有订单创建者能读自己的订单,并且只有状态还是 unpaid 的时候允许写。支付成功之后的订单,任何来自客户端的写操作都会被挡掉,哪怕有人篡改了前端代码。
云存储和定时触发器怎么放进分层
云存储(CloudBase Storage)在这套结构里属于数据访问层的延伸。上传动作不建议走云函数转发,让小程序端直接 wx.cloud.uploadFile 传到云存储,回调里拿到 fileID 再传给云函数入库,能省掉一次函数计算的时间和流量。真正需要云函数介入的是下载鉴权:私有文件用 wx.cloud.getTempFileURL 换临时链接,链接有效期到期即失效。
定时触发器适合放那些不该跟着用户请求跑的逻辑,比如每天凌晨清理过期未支付订单、生成前一日对账文件。触发器配置写在函数目录下的 config.json 里,用 Cron 表达式描述周期。
// 云函数 dailyClean/config.json
// 依赖:云开发定时触发器,Cron 为 7 位(含秒),时区固定 UTC+8
{
"triggers": [
{
"name": "cleanExpiredOrder",
"type": "timer",
"config": "0 0 3 * * * *"
}
]
}
这里有个坑:Cron 是 7 位并且按 UTC+8 解释,写成 6 位或者按服务器本地时区理解都会跑偏。我们第一次配 0 3 * * * 指望凌晨三点跑,结果触发器根本没注册上,排查了半天才发现位数不对。
免费额度与成本实测
拆完层之后我们盯着控制台看了完整一个月,取了改造前一个月和改造后一个月的对比。下面的数字是同一个项目在两个时期的实测记录,具体免费额度以你控制台当前展示为准,平台会调整。
| 计费项 | 改造前月用量 | 改造后月用量 | 变化 |
|---|---|---|---|
| 云函数调用次数 | 187 万次 | 62 万次 | -67% |
| 资源使用量 GBs | 4215 GBs | 1380 GBs | -67% |
| 数据库读操作 | 96 万次 | 41 万次 | -57% |
| 云存储容量 | 38 GB | 38 GB | 持平 |
| 云存储下行流量 | 210 GB | 74 GB | -65% |
| 月账单(超出免费额度部分) | 386 元 | 92 元 | -76% |
调用次数降下来主要靠两件事:一是把频繁的商品详情查询从云函数改成客户端直连数据库(配合「所有用户可读」权限),二是给库存和商品信息加了一层 60 秒的内存缓存,热实例有效期内不重复读库。
下行流量降得更多,是因为之前所有图片都走云函数中转了一次,改成 getTempFileURL 拿临时链接后,流量直接走 CDN,不再经过函数计算。
GBs 的下降则是冷启动优化和内存规格下调叠加的结果。同一份逻辑,包小了、内存从 256MB 降到 128MB,单次调用的 GBs 就按比例下来了。
迁移顺序和几个踩过的坑
回头看,如果重来一次,我们会按下面的顺序推进,能少走不少弯路。
flowchart LR
A[统计函数体积与调用量] --> B[抽出数据访问层 repo]
B --> C[抽出领域服务 service]
C --> D[接入层收口鉴权与校验]
D --> E[高频读改客户端直连 + 权限收紧]
E --> F[图片改临时链接走 CDN]
F --> G[加内存缓存与埋点]
第一步一定是先把数据访问层抽出来。因为所有函数都在裸调 db.collection(),不先收口就没法加缓存和统计,后面的优化全都无从下手。
踩过的坑里,有三个值得一提:
循环依赖。 service/stock.js 和 service/coupon.js 互相 require,Node.js 不报错但拿到的是空对象,调用时报 is not a function。解决办法是把共享逻辑下沉到第三个模块,不要让同层互相引用。
事务边界。 云开发的事务 db.runTransaction 必须在同一个云函数内完成,跨函数调用无法共享事务上下文。拆服务的时候要保证「扣减库存 + 核销优惠券 + 创建订单」这三步还在同一个函数里,我们最初把核销拆到独立函数,结果出现了券核销成功但订单创建失败的脏数据。
context 里的信息别丢。 拆分后子模块拿不到 context,如果在里面调用了需要 context 的能力(比如获取调用来源),要在入口显式传下去,直接透传整个 context 对象是最省事的做法。
别把云函数切成微服务
分层之后容易走向另一个极端:有人觉得既然要拆,那就一个接口一个函数,最后搞出 60 个云函数。这事儿我们试过,白费劲。
函数数量变多会带来三个新问题:每个函数都要独立冷启动,低频函数的冷启动比例反而升高;共用的工具库要在每个函数目录里放一份,或者依赖管理变得复杂;部署和日志排查的成本线性上升,一次业务改动要发布五六个函数。
比较稳妥的粒度是按业务聚合,一个聚合一个函数。像订单、商品、用户、运营后台,四个函数基本够用,每个函数内部的领域服务再按需拆文件。判断标准可以简单点:两个动作如果经常需要在同一个事务里完成,它们就该在同一个函数里。
至于云开发后续的能力演进,静态网站托管、HTTP 访问服务这些都在往「让云函数少干杂活」的方向走。把能下沉到平台的能力尽量下沉,自己维护的代码越少,账单和维护成本都越好控制。
如果你也在拆云函数,欢迎在评论区说说你的函数数量和包体积,我比较好奇别人的巨型函数能长到多大。
参考与延伸
- 微信开放文档 云开发 云函数:https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/functions.html
- 微信开放文档 数据库安全规则:https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/database/security-rules.html
- 微信开放文档 定时触发器:https://developers.weixin.qq.com/miniprogram/dev/wxcloud/guide/functions/triggers.html
- MDN Cron 表达式与时间调度基础:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date
微信小程序开发|云开发 CloudBase|云函数分层架构|BFF 接入层|数据库权限模型|定时触发器|冷启动优化|成本实测