别把业务逻辑都堆进云函数:微信小程序云开发的分层架构与成本实测

2026-09-23 01:21:44 0 次浏览
微信小程序云开发前端性能优化云函数BFF

一个做社区团购的小程序,上线第三个月云函数只有 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.jsservice/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 接入层|数据库权限模型|定时触发器|冷启动优化|成本实测

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。