小程序后端这件事折腾过的人多少都有点感触。我接到需求时其实是有点意外的要在微信小程序里做一个订单查询功能数据却躺在一台自建服务器的MySQL里。云开发自带的是文档型数据库跟现有订单表结构对不上强行做数据同步又是一堆脏活。最后我把方案定了下来小程序端通过云函数直连MySQL。这个方案在实际项目中挺常见的网上讨论也很多但真正能一次跑通的人不多。我把这次实操过程完整记录一下从方案选型、依赖选择、连接参数到踩坑排查尽量讲透。如果你也卡在“小程序怎么连数据库”“云函数和MySQL怎么打通”这类问题上这篇应该能帮你少走不少弯路。1. 场景与方案整体拆解1.1 业务背景为什么卡在“小程序连不上MySQL”项目本身不复杂一套现成的订单管理系统后端服务跑在传统服务器上数据库用的是MySQL 8.0里面已经积累了几年数据。现在要做一个小程序端让业务人员能随时查订单、改状态。最直接的思路是让小程序直接调现有后端接口但当时的现实情况是后端接口并没有全量对外开放临时补接口要排期而且部分接口的鉴权方式跟小程序侧对不上。于是我把注意力转到小程序云开发的能力上。云开发自带数据库确实方便但它是文档型的跟关系型数据库的模型差异很大。订单表涉及多表关联、事务更新、复杂的聚合查询如果硬切成文档模型原来的SQL逻辑基本都要重写风险太大。所以最终决定保留MySQL不动在云函数里用Node.js写一层数据访问服务小程序端通过云函数间接操作MySQL。这里要说明一个事实小程序端本身是不能直接连MySQL的。一方面小程序网络请求只允许走HTTPS域名而且要在后台做域名校验MySQL的3306端口根本不满足这个条件另一方面如果真把数据库账号密码写进小程序代码里等于把数据库裸奔在公网上随便一个人抓包就能拿到连接信息。所以中间必须有一层服务做代理云函数就是这个代理。1.2 方案选型为什么必须加云函数这一层当时我也考虑过其他方案比如自己搭一个轻量API服务再用小程序去请求它。这个方案技术上没问题但它意味着要维护一台服务器、处理部署、监控、扩缩容对一个小工具型项目来说成本偏高了。云函数的优势在于按调用次数计费、免运维、天然跟小程序生态打通在小程序里直接wx.cloud.callFunction就能用链路最短。当然云函数也不是没有代价。我踩过的第一个坑就是云函数运行在Serverless容器里默认超时时间只有3秒而第一次连接MySQL、执行SQL、返回结果的完整链路在冷启动状态下往往超过3秒。后面我会专门讲这个坑的处理方式。链路大概是这样的小程序端发起请求 - 云函数Node.js运行时 - MySQL数据库。云函数这一层承担了参数校验、SQL执行、结果格式化、异常捕获的职责。小程序端不再关心数据库在哪里、账号是什么只关心云函数返回的业务数据。这个设计还有一个好处后续如果要加权限控制、接口限流、日志审计都可以在云函数层统一处理不需要动数据库结构。从长期维护的角度看这个中间层是值得加的。2. 云函数环境准备与依赖选型2.1 创建云函数并搭好本地工程在微信开发者工具里开通云开发环境之后在cloudfunctions目录下右键可以新建云函数我命名为mysqlDemo。创建后会自动生成index.js和package.json结构很简单。我习惯先把本地依赖装好再上传。在cloudfunctions/mysqlDemo目录下执行npm install mysql2安装完成后node_modules目录会出现在本地。之后在开发者工具里右键云函数目录选择“上传并部署云端安装依赖”或者选择“上传所有文件”。根据我的实测云端安装依赖这种方式有时候会因为网络问题失败尤其是当package-lock.json里的依赖版本较新时。稳妥的做法是本地安装依赖后选择上传所有文件直接把本地node_modules一并传上去。缺点就是包体积会大一些但云函数本身对代码包大小有上限在合理范围内稳定优先。另外部署时可以顺便把云函数的超时时间调大。在云开发控制台的云函数配置里把“超时时间”从默认的3秒改成20秒。这一步非常关键我第一次没改连数据库的请求基本必挂。2.2 为什么我选mysql2而不是mysql新手很容易在mysql和mysql2这两个包之间纠结。我用的是mysql2理由很直接。mysql是Node.js社区早期常用的驱动但它基于回调风格写出来的代码层层嵌套处理并发和异常很不顺手。虽然可以用util.promisify包装但总归是绕了一层。mysql2原生支持Promise和async/await代码写起来干净很多。mysql2还支持预处理语句Prepared Statement配合connection.execute()使用?占位符传参可以有效避免SQL注入。这个能力在云函数这种对外暴露的入口上尤其重要。另外mysql2对utf8mb4字符集的支持更完整表情符号和生僻字都能正常存取性能上实测也比老驱动稳定一些。简单说新项目直接上mysql2就好没必要在mysql上浪费时间。3. 核心代码实现连接、查询、事务与批量操作3.1 连接MySQL并完成第一次查询先看一个最基础、能跑通的例子云函数入口接收一个userId返回对应用户信息。// index.js const cloud require(wx-server-sdk) const mysql require(mysql2/promise) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) exports.main async (event, context) { const connection await mysql.createConnection({ host: process.env.MYSQL_HOST, port: Number(process.env.MYSQL_PORT || 3306), user: process.env.MYSQL_USER, password: process.env.MYSQL_PASSWORD, database: process.env.MYSQL_DATABASE, connectTimeout: 20000, timezone: 08:00, dateStrings: true, }) const [rows] await connection.execute( SELECT id, name, phone FROM user WHERE id ?, [event.userId] ) await connection.end() return { code: 0, data: rows[0] || null, } }这里有几个细节需要说明。mysql.createConnection每次调用都创建一个新连接用完必须end掉否则连接会泄漏。但这只是最基础的写法后面我会换成连接池。execute和query的区别在于execute走预处理参数用?占位适合重复执行、参数固定的场景安全性更高query更灵活可以直接执行SQL字符串但参数拼接时要格外小心注入问题。我统一用execute。dateStrings: true是把时间字段以字符串形式返回避免Node的Date对象在序列化时出现时区偏移的问题。MySQL的DATETIME字段在没设置时区时很容易差8小时这个参数能省很多事。在这个阶段记得先在云开发控制台给云函数配置环境变量。把数据库地址、端口、账号、密码、库名都放到环境变量里不要在代码里写死。尤其是密码这种敏感信息写死之后一旦代码上传到仓库就等于泄露了。提示第一次测试时先在控制台云端测试一下确认连接能通再从小程序端调。不要一上来就从小程序端调试链路太长问题不好定位。3.2 连接池的复用与实例复用问题实际业务不可能像上面那样每请求建连接。云函数是Serverless架构同一个实例可能被多次复用也可能随时被回收。所以正确的做法是维护一个连接池每个实例只初始化一次。// db.js const mysql require(mysql2/promise) let pool null function getPool() { if (!pool) { pool mysql.createPool({ host: process.env.MYSQL_HOST, port: Number(process.env.MYSQL_PORT || 3306), user: process.env.MYSQL_USER, password: process.env.MYSQL_PASSWORD, database: process.env.MYSQL_DATABASE, waitForConnections: true, connectionLimit: 5, queueLimit: 0, enableKeepAlive: true, keepAliveInitialDelay: 0, connectTimeout: 20000, timezone: 08:00, dateStrings: true, charset: utf8mb4, decimalNumbers: true, }) } return pool } module.exports { getPool }然后在入口文件里// index.js const cloud require(wx-server-sdk) const { getPool } require(./db) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) exports.main async (event, context) { const pool getPool() const [rows] await pool.execute( SELECT id, name, phone FROM user WHERE id ?, [event.userId] ) return { code: 0, data: rows[0] || null } }这里面有个取舍问题connectionLimit不能设太大。云函数在并发高的时候会启动多个实例每个实例各自维护5条连接50个实例就是250条连接很可能把MySQL的连接数打满。我一开始设了10后来压测时发现数据库max_connections报警改回5之后稳定多了。waitForConnections: true表示连接池满了之后请求排队等待而不是直接报错。queueLimit: 0表示队列长度不限制。这两个参数配合使用可以避免瞬时并发把数据库冲垮。还有一个细节连接池里的连接长时间空闲MySQL服务端会主动断开。enableKeepAlive: true和keepAliveInitialDelay: 0可以在连接空闲时发送心跳包降低“连接被服务端断开”的概率。当然连接池会自己处理失效连接但加上心跳更稳。注意连接池是挂在云函数实例的内存里的。实例被回收后连接池自然销毁实例存活期间连接池一直复用。冷启动时首次调用会经历一次建池过程耗时比后续调用多几百毫秒到一秒属于正常现象。3.3 事务处理与批量写入的完整示例日常开发中转账、下单这种多步操作必须用事务保证一致性。云函数里操作MySQL事务跟普通Node服务没有本质区别。const { getPool } require(./db) exports.main async (event, context) { const pool getPool() const conn await pool.getConnection() try { await conn.beginTransaction() await conn.execute( UPDATE account SET balance balance - ? WHERE id ? AND balance ?, [event.amount, event.fromId, event.amount] ) const [rows] await conn.query( SELECT amount FROM account WHERE id ? FOR UPDATE, [event.toId] ) if (rows.length 0) { throw new Error(转账目标账号不存在) } await conn.execute( UPDATE account SET balance balance ? WHERE id ?, [event.amount, event.toId] ) await conn.commit() return { code: 0, msg: 转账成功 } } catch (e) { await conn.rollback() console.error(事务失败, e) return { code: 1, msg: e.message } } finally { conn.release() } }事务里有三个关键点。第一getConnection()和execute()不同事务必须在同一个连接上执行所以我用pool.getConnection()从池里取出一条专用连接事务结束后release()还给连接池而不是end()销毁它。第二更新操作要带条件判断。比如扣款语句里的AND balance ?如果余额不足这条SQL影响行数为0后续就可以提前终止业务。不要先查余额再扣款并发环境下两个步骤之间数据可能已经被别的请求改掉了。第三任何一步失败都要rollback并且把错误信息返回给调用方。console.error一定要打方便云端日志排查问题。再来看批量插入。小程序端收集了一批数据要一次性写入MySQL。如果循环单条插入几百条数据要执行几百次网络往返性能很差。更合理的做法是拼一条多值SQLconst { getPool } require(./db) exports.main async (event, context) { const pool getPool() const records event.records || [] if (!records.length) { return { code: 1, msg: records不能为空 } } const placeholders records.map(() (?, ?, ?)).join(,) const flatValues records.reduce((acc, cur) { acc.push(cur.id, cur.name, cur.score) return acc }, []) const conn await pool.getConnection() try { await conn.beginTransaction() await conn.execute( INSERT INTO score (id, name, score) VALUES ${placeholders}, flatValues ) await conn.commit() return { code: 0, msg: 成功写入${records.length}条 } } catch (e) { await conn.rollback() console.error(批量写入失败, e) return { code: 1, msg: e.message } } finally { conn.release() } }注意flatValues的顺序要和SQL里的占位符严格对应否则数据串位。另外单条SQL能承载的占位符数量和包大小是有限制的MySQL的max_allowed_packet默认只有4MB或64MB。如果批量写入的数据量特别大建议分批比如每批500条避免单次SQL过大被服务端拒绝。这里用事务包住批量写入好处是中途失败可以整体回滚不会出现“写了一半成功一半失败”的脏数据局面。4. 连接参数、密钥管理与生产环境细节4.1 容易被忽略的五个连接参数很多人连接MySQL只填host/user/password/database跑通之后就再也不管了直到线上出问题才回头翻参数文档。我这次配置连接池时把几个关键参数都过了一遍。connectTimeout建立连接的超时时间默认10秒。云函数冷启动时实例初始化加上网络握手10秒可能不够。我调到20秒基本能覆盖最坏情况。timezone控制时间字段的时区偏移。服务器MySQL跑在UTC本地环境是08:00如果不设置返回的时间会差8个小时。加timezone: 08:00后查询出来的时间就是本地时间。dateStrings把DATE、DATETIME字段返回为字符串而不是转换成JS的Date对象。云函数返回值需要序列化成JSON直接转字符串能避免序列化格式不一致的问题。charset设置为utf8mb4。MySQL 5.7以下的老库默认可能是utf8mb3存不了表情符、生僻字。现在新项目基本都统一用utf8mb4。decimalNumbers把DECIMAL和NUMERIC字段默认返回为数字。默认情况下mysql2为了保持精度会把这些字段作为字符串返回。如果业务层需要做数值运算开着这个参数会更方便。如果涉及超大金额或高精度计算建议还是保持字符串在业务层用专门的精度处理库避免浮点误差。还有一个容易被忽视的大整数问题BIGINT字段超过JS安全整数范围Number.MAX_SAFE_INTEGER时默认会被转成字符串。如果数据库里有雪花ID、大整数主键返回给前端时要确认数据类型避免前端拿到错误的数值。4.2 密钥、账号权限与网络安全收口数据库账号密码这种敏感信息一定不能写在云函数代码里。云开发控制台为每个云函数提供了环境变量配置入口我在里面维护了这样一组变量MYSQL_HOST内网或公网IP MYSQL_PORT3306 MYSQL_USERapp_user MYSQL_PASSWORD一个足够复杂的随机密码 MYSQL_DATABASEorder_db代码里统一通过process.env读取代码仓库里永远不会出现真实的账号密码。如果需要切换数据库环境测试库、生产库只需改环境变量重新部署云函数即可不需要改代码。数据库账号要单独创建不要直接用root。我给业务账号只授权了指定库的增删改查权限CREATE USER app_user% IDENTIFIED BY 这里填强密码; GRANT SELECT, INSERT, UPDATE, DELETE ON order_db.* TO app_user%; FLUSH PRIVILEGES;order_db.*限定了库范围SELECT/INSERT/UPDATE/DELETE限定了操作类型比ALL PRIVILEGES安全得多。这样即使账号泄露攻击者也只能操作这一个库不能翻其他库更不能改表结构。网络层面是个更现实的问题。云函数运行在云厂商的Serverless网络里出口IP是不固定的如果MySQL服务器只允许特定IP访问云函数的请求很可能被拒绝。我在测试阶段采用的是折中方案先把MySQL所在服务器的防火墙放行3306端口确认功能跑通。生产环境更推荐把云函数和数据库放到同一个内网网络里通过内网IP访问端口不对公网暴露安全性会好很多。如果只能用公网访问至少做两件事一是MySQL的bind-address不要设置成0.0.0.0尽量限制在可信来源二是数据库账号尽量设置成app_user内网网段或app_user云函数出口网段不要用%给所有来源放行。注意云函数实例的出口IP不固定白名单机制在公网场景下很难精确控制。能用内网连接就用内网连接这是我在实际部署中总结出的最大经验。5. 踩坑实录与排查速查5.1 高频报错对照表这次实操过程中遇到几个报错我把它们整理成了速查表按出现频率排序。报错信息可能原因处理方式ERROR 1045 (28000): Access denied for user账号密码错误或账号不允许从当前主机连接检查环境变量里的密码确认MySQL授权范围覆盖了云函数出口来源connect ETIMEDOUT网络不通3306端口被防火墙拦截或云函数到数据库的路由不通放行3306端口确认数据库监听地址不是仅限本机优先改走内网连接PROTOCOL_CONNECTION_LOST连接空闲过久被服务端断开或网络抖动使用连接池并开启keepAlive执行SQL前先确认连接可用ER_NOT_SUPPORTED_AUTH_MODEMySQL 8默认认证插件跟客户端不兼容升级mysql2到最新版本或调整MySQL账号的认证插件为兼容模式Connection lost: The server closed the connection云函数实例长时间存活连接被MySQL超时回收连接池会自动检测失效连接如果频繁出现调大MySQL的wait_timeout或缩短空闲检测周期返回数据超限云函数返回值超过单次响应大小限制控制返回字段数量分页查询或把大数据改为分段获取ER_NOT_SUPPORTED_AUTH_MODE这个报错值得一提。MySQL 8默认使用caching_sha2_password认证插件而部分老版本Node驱动只支持mysql_native_password。我一开始用的mysql2版本比较旧连上去直接报这个错。升级到最新版本后问题消失。如果因为特殊原因不能升级连接库也可以在MySQL里把账号认证改回兼容模式但我不推荐长期依赖这种方式。5.2 冷启动与并发量的现实问题Serverless的冷启动问题在云函数连数据库的场景下会被放大。第一次调用云函数需要冷启动实例、加载代码、初始化连接池整体耗时可能跑到2到3秒。加上默认超时只有3秒用户第一次点击查询按钮时很容易觉得“卡住”了第二次再点就快很多。应对冷启动有几个办法。第一在云开发控制台给函数配置一个定时触发器每隔几分钟调用一次让实例保持“热”的状态。第二把连接池初始化放到模块顶层实例一启动就建池而不是等第一次请求进来才建。第三前端在小程序启动后主动做一次静默预热调用把冷启动时机提前到用户真正操作之前。并发量上来之后真正危险的是数据库连接数被打满。每个云函数实例各自维护自己的连接池实例数越多总连接数越高。我在压测时发现当并发请求到100左右时数据库端频繁报Too many connections。处理办法是把连接池的connectionLimit调小比如5同时与数据库端确认max_connections足够支撑云函数实例数乘以单实例连接数的峰值。还有一个相关的坑如果业务请求量波动很大建议在云函数入口做简单的并发控制。比如针对热点接口加一个“信号量”判断超过阈值直接返回繁忙提示而不是让请求全部穿透到数据库层。云函数本身有并发限制但如果每个请求都去数据库打一次数据库会成为最先扛不住的瓶颈。5.3 数据一致性与返回体细节云函数连MySQL最容易出问题的是“我以为数据写进去了但实际上没写进去”。这种问题多半出在提交环节。比如事务操作里SQL执行成功了但忘记commit云函数返回后连接被回收事务自动回滚。排查时要在代码里明确检查commit和rollback是否成对出现。另一个细节是返回数据格式。云函数返回给小程序端的对象会被序列化成JSON如果直接返回MySQL的行数据要注意字段类型。MySQL的TINYINT(1)在mysql2中默认返回数字0或1而不是布尔值DECIMAL默认是字符串BIGINT超出安全整数范围时是字符串。前端拿到这些值时要做好类型处理避免因为“1 ! true”这类问题导致判断逻辑出错。还有一个我亲身踩过的坑返回数据里有Buffer类型的字段比如BLOB直接放进返回对象里会被序列化成一段很长的字节数组不仅数据看不懂还会撑大响应体。后来遇到这种字段我都是先用toString(base64)转换再返回给前端。字段命名也建议在SQL查询时使用AS指定别名统一转成小程序端好读的驼峰命名。数据库表字段通常是user_name、created_at这种下划线风格小程序端拿到之后要么在云函数里做一次映射要么前端再转换。我选择在云函数里处理小程序端拿到的数据就是干净可用的。写在最后这次把云函数和MySQL打通之后我最大的感受是方案本身不难难在细节。连接池怎么配、超时时间怎么设、环境变量怎么放、账号权限怎么收任何一个环节没考虑到位线上都会出问题。我个人最深刻的体会是“能走内网就不要走公网”这句话实践过才知道有多重要。公网连数据库出口IP不固定安全组和防火墙来回调整排查问题时非常痛苦改成内网连接之后这些问题几乎消失。最后再分享一个小技巧上线之前先在云开发控制台把云函数手动测试一遍确认连接和SQL都正常再从小程序端调试。我见过不少同事跳过这一步结果小程序端反复报错排查半天才发现是数据库账号权限问题。顺序对了排查效率会高很多。