#服务迁移 最近把服务全部迁移到了 cloudflare 上,开个 thread 记录一下踩了哪些坑与心得(碎碎念)。图片是迁移前后大概的对比。
首先是语言的选择,老服务用的是 Java 开发。作为一个独立应用这不是一个好的选择,第一 Java 应用占用资源更高,对于服务器的要求也会更高,相对的花在服务器上的钱就会更多。目前我用的是一台 2 核4g 的阿里云 ECS,一个月大概 280 元左右。
第二 开发效率上,Java 与 php、nodejs、python 之类的比完败,而上线速度对于独立产品至关重要。第三 在 serverless 的支持上不如 js ,比如 cf worker 就不支持。第四 java 没办法使用 cursor,用不回 IDEA 了。
第五 生态上,使用 js 的话会有很多有趣的包,而 java 大多数是企业级应用,通常独立产品在这个领域没有丝毫胜算。 这么多缺点,那为什么在一开始还选择使用 Java。其实我在一开始就准备使用 PHP 或 node,我甚至还花了很多时间去学习它们,但是要么学到一半就放弃了,或者学着学着,我的热情消耗殆尽
大概花了两年时间去反复学这些最优的技术栈,学了忘,忘了学。有一天我突然发现,两年过去了,精力全内耗在这些虚的上,什么产品都没做出来,我甚至忘了我最开始想做什么产品。然后我做了一个决定,会什么用什么,不再纠结。然后后端用老本行 java,前端直接用 jquery、html、css。最重要的是先跑起来
现在业务基本稳定下来了(收入不稳定🤡),抽个时间把服务换成 node 拥抱 js 生态。首先肯定要使用 ts ,更容易发现问题,用过 java 之后用 ts 也还好。然后是后端框架,java 的时候一直用的 spring boot 那一套,转 ts 后,我最开始想用的是 nestjs,跟 java 的很像。但是后来放弃了。
原因主要是,一 nestjs 很强大,功能很全,很像 spring boot,这就造成了它很重,学习成本也不低。二 我花了好久才理解,cf worker 与 nodejs 是不同的运行时,很多 node 包没办法用。既然要往 cf 上迁移,所以这点就 pass 掉了。 然后发现了一个新框架 honojs,在调研后,最终选择了它。原因有两个:
一 它非常的轻量,也非常的简单,心智负担小。对我的业务来说足够了。二 它可以很方便的在不同运行时上运行。比如原生 node、bun、cf worker、aws、vercel。。。这样就尽可能不被一个平台绑架。 然后是 orm 框架,java 时使用的是 mybatis plus。迁移后想找一个类似的。
ts 的 orm 框架选择似乎并不多,主要调研 prisma 与 drizzle 。然后个人感觉 prisma 更强大,而 drizzle 更简单,我选择了学习成本更低的 drizzle。因为我目前也用不上特别强大的功能。 常用工具包的选择上,java 之前用的 hutool,大而全。我一直想在 js 生态中找到类似,可惜一直没找到。
只能不同功能导入不同的包,首先基础工具包用的 Radash,相对于 Lodash 更轻也更新。时间处理用的 dayjs,请求库用的 ky,使用这个的原因也是因为它很轻量。
为什么一直强调轻量,一是 cf worker 对发布的包大小有限制,太大会无法部署。二是 越重的包对于 node 的依赖就会更深,很可能无法在 cf worker 的运行时上运行。在 worker 上导包真有一种开盲盒的感觉,在启动之前你根本不知道能不能用,使用 worker 这点一定要注意。
迁往 cf 一个很重要的原因是它免费计划很慷慨。每天 10w 次请求,数据库每天 10 w次写入,500w的读取。对我的业务足够了,我能用超额,作梦都会笑醒。但是我最终还是开了每月 5 美元的付费计划。一个重要的原因是免费版本一个请求只能占用 10ms 的 CPU 时间,一个复杂的接口可以没执行完就挂了
另外就算付费计划也有限制,一个接口请求最多 15 秒占用,定时任务或消息队列是 15 分钟。迁到 worker 接口的运行时间一定要评估好。太耗时的就别想了。我就遇到这方面的坑。
js 的异步方法使用 async 修饰,当调用的时候前面加上 await 会等待其执行完成,再执行后面的代码。没有加的话就不会影响后面的代码执行,而是异步执行。我在记录日志或者消息推送这类不影响后续逻辑的代码调用,我通常不用 await ,我不想让它阻塞后续逻辑,而是异步执行
但是我发布到 worker 上后,发现这些没有 await 修饰的调用,大概率不被执行。原因是它没有阻塞住主进程,后续代码继续执行直到完成了请求响应,然后进程就结束了,那些还在执行或者没有执行的异常任务全部直接丢弃了。最坑的是本地开发环境上执行这些异步都是正常的,到正式就会丢弃。
如何解决?最简单的方法就是所有调用异步的方法都加一个 await。但是当一个接口await 过多,你就会发现,这个接口响应会变的很慢。我的 paddle 回调就遇到这个问题,paddle 发现调用我的回调接口一直超时,然后一直拼命重试。而它一超时就会主动关闭连接,后面逻辑也不会再走。
这个时候可以改用 worker 的 waitUntil 方法,它可以保证在请求结束了后,仍然执行完异步方法,前提不超过 CPU 占用限制。 (太晚了,明天接着写吧 developers.cloudflare.com/workers/runtim…
我总共执行了 2 次迁移操作,第 1 次迁移后发现大量报错,感觉在短时间内没办法解决,所以又迁回去了,然后慢慢找问题。线上错误信息如下:
Error: Cannot perform I/O on behalf of a different request. I/O objects (such as streams, request/response bodies, and others) created in the context of one request handler cannot be accessed from a different request's handler.
This is a limitation of Cloudflare Workers which allows us to improve overall performance. (I/O type: ReadableStreamSource) 翻译过来如下:
错误:无法代表不同请求执行 I/O。在一个请求处理程序的上下文中创建的 I/O 对象(如流、请求/响应体等)不能从不同请求的处理程序中访问。这是 Cloudflare Workers 的一个限制,它允许我们提高整体性能。(I/O 类型:ReadableStreamSource)
这个异常看的我有点懵,我是怎么能做到在一个请求中访问到另一个请求的内容。超出认知,每个请求不是相互独立的吗?网上查了一下没找到什么有用的信息。然后我把异常信息丢给 cursor 让它扫描整个代码库,排查哪些代码会引起这个错误。cursor 给出两个地方,第一个地方是我打出入口日志的地方。
本来为了不影响 request 对象,获得请求参数我用了 c.req.raw.clone().json() ,想用克隆后的对象。但是这个克隆操作可能造成了不同请求的影响,后面改成了 c.req.json() 解决。 第二个地方,hono 中很多地方都要用到 context 对象,数据库、缓存、响应等等,而默认 context 对象要在请求的入口获得。
也就是说要从请求的入口一直往下传。这就造成每个方法都要有一个 context 的入参,很麻烦。在 Java 中遇到这种情况,我会用 ThreadLocal 解决。所以我想 Js 中是不是也有类似的东西,还真让我找到了 -- globalThis 。我在请求的入口处将 context 赋给 globalThis.honoContext 的属性。
后续我在想使用 context 对象时,直接调用 globalThis.honoContext 就可以获得。使用后发现达到了想要的效果。但是很不幸这可能触发了 worker 的共享限制。好在不久前 hono 官方发布了全局获取 context 的方法,替换成它后解决。 hono.dev/docs/middlewar…
首先这个问题很坑,因为我没办法复现。不管本地开发环境,还是线上环境。本地上运行完美,没有任何报错,功能一切正常。线上也并不是必现的。即使我跑多线程模拟并发环境也没有重现。我只能先把这两个可疑的地方改了发到线上,看看还会不会出现。不过,好在后续没有再次出现,基本断定是这里的问题。
还有一个容易忽视的问题,那就是时区问题。worker 运行获得的时区是 UTC-0。在老服务中我用的时区是国内的 GMT+8 。两者相差了 8 个小时。不过因为数据库保存的是时间戳,对用户影响不大,但是对于 sql 统计、定时任务会有些影响,比如我本来有一个早上 7 点(国内时间)的定时任务
它会查询昨天新增用户的情况,逻辑就是查询昨天 0 点到昨天 23 点59 分 59 秒的时间内用户表的新增数据。结果迁移后我发现,新老服务数据不一致。排查后发现是时区的问题,首先 7 点跑脚本,因为时区相差了 8 个小时,在 worker 还处于昨天,相当于查了昨天的昨天。然后我就把任务时间改成了 8 点半
另外相差 8 小时,如果想按国内的时间来分析,那开始与结束的时间也要往前推8 个小时。还有一点要注意,java 获得的时间戳默认是毫秒级,长度是 13 位,而 js 的时间戳默认是秒级,长度是 10 位。这一点要做好转换保持一致,要么全用 13 位,要么全用 10 位,不然你可能会看到1970 年或 5w年后的时间
接下来说说数据库的事,首先之前老服务用的docker 跑的 mysql 。迁移的目标调研了几个,一个是 cf 的 D1,一个是Supabase 的 PostgreSQL ,一个是 turso 。首先他们的免费额度十分慷慨,足够我项目使用。其中 D1 与 turso 基于 sqlite,supabase 基于 postgresql。
最终我选择了 D1,主要考虑因素是,一 易用性,D1 本身就是 cf 的产品,搭配 worker 集成使用更方便。二 网络开销,worker 与 D1 同属 cf 的网络中,甚至还可以配置在同一个区域, 减少访问数据库的网络开销,也间接提升速度。而 supabase 与 turso 就会多出这些开销。
迁移后,有用户反馈账号无法登录。我测试了一下自己的账号登录正常。分析日志也没有问题,用户输入的邮箱与密码是正确的,但是就是提示账号或密码错误。百思不得其解,然后我把用户的邮箱放到 sql 里面在D1 上查询,然后竟然真的找不到。刚开始我怀疑是不可见字符的问题。
后来定位到问题,我人都麻了。从 mysql 迁移到 D1(sqlite)。我已经做好了心理准备,因为还是有不少差异的,比如 sqlite 支持的类型就比 mysql 少很多,不能一一对应,另外就是 sqlite 即使你定义了类型,但是你可以不按类型存也不会报错,还有一些语法上的差异等。但是我忽略了大小写敏感的问题。
我创建 mysql 时通常会设置成大小写不敏感。这应该也是常规操作,写代码这么多年用过的数据库几乎都是大小写不敏感的。这已经形成肌肉记忆,完全没有想过 sqlite 是大小写敏感的。回到上面用户的问题,用户注册的时候输入的邮箱是 Abc@gmail.com。而在登录的时候用的又是 abc@gmail.com。
这在老服务 mysql 上是没问题的,因为大小写不敏感,这会被判定成同一个用户。而在 D1 上这就完完全全两个用户。 然后想着怎么解决,首先没找到 D1 有 mysql 那样配置全局大小写不敏感的配置。需要在创建表的时候用 COLLATE NOCASE 关键字,指定某个字段忽略大小写,然后我就想修改表加上这个关键字。
然后发现 sqlite 的ALTER TABLE语句仅支持对表名进行修改以及新增列,而不能直接修改已存在的列的定义(这也是一个坑点)。修改表是不可能了,只能改代码逻辑了。分三步,一 我把项目中所有操作了 email 的 sql 操作抽成了一个公共方法,在查询时使用 lower(email) 强制转成小写。
二 在请求入口处,如果检查到参数有 email ,就toLowerCase() 。三 合并清除因为大小写不一致导致的重复账号。代码写的我很难受,有种堆💩山的感觉
大小写问题临时解决了,观察了几天日志,发现会偶尔出现"Error: D1_ERROR: Network connection lost." 错误。大概千分之几的概率。不知道是我代码的问题,还是 D1 自身的问题,我上网查了一下,在 cf 的论坛与 discord 社区,也有不少人反馈类似的问题,大部分没有得到解答。
唯一稍微有点用的解答是让自己做重试,还表明正常是会出现的😕。我最终选择 D1 很大的原因是减少网络开销,如果都这样还有网络连接丢失的问题,我对 D1 稳定性存疑,这个问题还没完全解决,有进展我再更新。
接着说企业邮箱,之前用的阿里云的企业邮箱,总体没有什么大问题,但是免费版的入口似乎被隐藏了。不能绑定多个域名。发送上限限制不透明。我想换一个支持多域名的服务。调研了一些,一 gmail 搭配 cf 使用,遇到过丢失的情况,并且还是能看到 gmail 的地址。
二 icloud,icloud 支持绑定多个域名,但是 icloud 的稳定性,有口皆碑。另外,在回复用户邮件时,经常遇到对方无法接受,原因是他 icloud 满了,所以很悬。三 飞书,目前免费,不确定后续情况。我想找个确定一些的。最后选择了 zoho,选择原因便宜,一个用户一个月 $1,通常只需要一个用户就可以了
另外支持无限域名。功能上也比较丰富。如果不用一些高级功能,免费版也够用。有一点需要注意,zoho 不同国家付费方案不同,国内版 zoho,一个人每月5 元,乍一看比较便宜,但是要 5 个用户起定。推荐选国外。
在 java 应用中我直接使用 smtp 协议发送邮件,而在worker 上通过 smtp 发送邮件会失败,因为不支持这个协议。需要走 http api 的方式发送(免费版没有)。
之前查看数据库我用的是 DataGrip,它不支持 D1(至少我用的版本没有)。而 D1 的网页端异常简陋。目前我主要使用两个,一个是 Drizzle 出的 drizzle studio 插件,很方便 chromewebstore.google.com/detail/drizzle…
另一个是 tableplus,drizzle studio 很方便,但因为是浏览器插件功能较少,sql 联想、历史保存之类的没有。适合临时使用。tableplus 功能比较齐全,另外我有 setapp,可以免费使用,单独买太贵了,不推荐。
关于日志收集与分析,之前Java使用的是阿里云的日志服务,类似 ELK 那一套。 cf worker 上默认不留存日志,只能看实时日志。如果想转存到其它日志服务可以使用 tail worker 功能实现。不过 worker 的付费版,可以免费用 baselime 的服务。后者已经被 cf 收购。 可以一键集成。 developers.cloudflare.com/workers/observ…
关于客户支持问题,不像一些其它云厂商,cf 默认只能提账单、账户、注册的工单,不能提技术问题。另外想在线支持,需要升级到 Business 计划,一个月 $250。而且这个计划主要是使用 cdn 相关产品的权限,而非 worker。不太可能为了客户支持单独去开。这也理解,毕竟用户群体比较大,聚焦服务核心用户。
那么如果真的遇到技术问题怎么办,两个途径,一 上 cf 的开发者论坛发帖求助,首先时效性很难保证。另外如果不是很常规性的问题,很可能没人能解答。我就遇到一些问题帖子,发现几个月后都没有一条回复,有回复也是类似“嘿,老兄,我也遇到了同样的问题,你后来解决了没有”
二 是加入 cf discord 社区反馈,时效性相对更好一些,不过考虑到时差问题,你在的时候其它人可能还在睡觉。另外就是官方强调了这里面的工作人员,"并不是技术支持人员,而是普通开发及技术专家,他们是在空闲的时候在这里自愿义务的解答大家的问题。"所以在这里问问题,最好做好预期管理与情绪管理。






