# 凤蝶区块历史及其问题排查记录

区块为应用提供便捷的数据编辑与快速发布上线的功能，在内部 egg 应用中使用非常普遍。本文主要介绍一下区块功能实现方案的历史，以及区块一个文件丢失的遗留 bug 排查过程。

## 凤蝶区块 1.0

凤蝶区块功能源自最初的 cms 区块，cms 区块就是一段 html 代码片段，中间有一些占位符，可以让运营编辑，功能上是支持把页面的一部分支持动态配置。  ![image.png](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS8yY2QwZjMyZi1hY2EzLTQyZDktOTk1Ni0wOTAzZjYzNWFlNjgvaW1hZ2UucG5nIiwidHlwZSI6ImF0dGFjaG1lbnQiLCJpYXQiOjE3OTA0Mzg0MDAsImV4cCI6MTc5MDUyNDgwMH0.yTQfTuWopkwuDr2s8TXl5yhflz-vgxIdmHlWOd_-o4Q) 最初版本的凤蝶也是同样的基本思想，但是凤蝶设计把数据描述独立出来，数据部分慢慢成为了区块最核心的功能，模板功能慢慢被使用者弃用了。1.0 fengdie 插件功能设计非常简单，agent.js 监听 `uiweb/fengdie` 目录，文件夹有变化，直接触发一个更新的消息。然后 app.fengdie 依次读取本地文件夹所有的 json 文件，读取内容，注入到 `app.fengdie` 。

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS9jMWYwYzM4Ni1jODk2LTQ3ODMtODBkZC0yY2M4ODU5Y2NlZWMvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzODQwMCwiZXhwIjoxNzkwNTI0ODAwfQ.AhuXe-Wv7-lnm11lsqsK6X_JlapmHvGCjm2Pl7ck5t0) **文件初始化**由 chair 构建脚本负责初始化，读取凤蝶平台的 `/api/cms/install` 接口，得到所有依赖的 json ，写到本地。开发环境由 chair-bin 负责实现同样的问题。

**文件更新**由凤蝶平台完成，凤蝶服务器直接通过 zpublish 服务发布文件到应用集群，文件更新后，由fengdie 插件负责更新内存数据。

这套方案就是主动推的模式，文件由凤蝶主动推送到应用服务器，这样有几个问题

* 依赖环节过多，插件同时依赖了凤蝶站点和构建脚本，本地开发还需要手动触发初始化
* 推模式发布慢，大的应用有几千台机器，zpublish 批量推送非常慢，并且 zpublish 稳定性是一个瓶颈，大批量推送，经常会有失败
* 更新机制，每次都是全量触发文件更新，会导致更新的瞬间，cpu 消耗过大

## fengdie-pull

针对 1.0 版本的问题，后来不四开发了 egg-fengdie-pull 插件。相对于 1.0 的 push 模式，新版本最大的特点是改用 pull 模式。实现逻辑如下

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS81M2EzMTMyMy04OWUzLTQ5OTMtODM3Yi0xYmI5YmNiMDRjNzgvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzODQwMCwiZXhwIjoxNzkwNTI0ODAwfQ.I54NSBdI7-J-g2Ttv1VKlrZyrQWMI-DmY-eOGRX3H3g) 主要区别在于文件初始化和更新逻辑，全部由插件自身从 oss 下载文件，更新逻辑由凤蝶主动推送改成客户端拉模式。每分钟触发一次 head 请求，检查配置文件是否有更新，更新的时候下载更新的文件，然后只更新修改的部分。

这样模式，一方面 oss 稳定性更高，同时客户端主动拉，应用机器数量不再是瓶颈，发布时间相对更加可控。更新也是局部更新，不会出现批量更新导致服务器 cpu 负载过高。

## 问题

新的 fengdie-pull 插件很快替代了老的模式，但是实际使用中，遇到一个非常严重的问题，**文件丢失**。

所以在业务线最佳实践里，凤蝶区块使用都是要做非空判断的，比如

```javascript
  const config = this.fengdie.insmutual_health_notice_switch || {};
  const { date = '2019-05-01' } = config;
```

区块数据有些可以兜底，有些是没法兜的。比如配置的一些抽奖活动 id ，没有活动就挂了，这是一个非常严重的问题了。

fengdie-pull 插件在 4.x 版本修复了数据丢失的问题，但是 chair 1.4 版本还是使用老的 3.x 有问题版本。并且，chair 中间件维护者，也不打算继续维护老版本的插件了。线上出现好几次问题，我们只能通过服务器重启或者重新发布区块解决。但这个问题始终是一个定时炸弹，随时可能爆炸。

当然，升级框架是一个彻底解决问题的好方法，但是保险业务 bff 应用集合了八百多个接口，测试回归成本太高了。有点积重难返了，就像凤蝶最初的渲染容器 cmspromo 系统，上面承载了一堆历史遗迹，render 替代渲染容器将近四五年了，cmspromo 还是保留在线运行着，只是机器非常少罢了。

无法升级，可以先让这个应用变得慢慢不重要，随着时间流逝，改造的风险也就越来越小了。

### 第一次尝试

面对上面的问题，chair 官方已经没法依靠了，升级也没那么快，那只能靠自己了。

花了半天研究了一下 egg-fengdie-pull 插件的源码，代码不多，看由于引用了一个 sdk-base 的包，而且代码流是通过事件触发，有点没看懂。

通过调试，找到代码 `fengdie.xxx` 读取逻辑部分代码

```javascript
Object.defineProperty(this, key, {
  get() {
    // 优先从拉模式获取，文件不存在时通过推模式容灾
    if (this._pull.store[key]) return this._pull.store[key];
    if (this._push.store[key]) {
      // 因为现在文件都落盘了
      this.emit('warn', [ '[failover][%s] from file', key ]);
      return this._push.store[key];
    }
  },
});
```

但线上问题出现的概率非常低，无法重现。只能靠猜测，也行 `fengdie.xxx` 调用的时候， `this._pull.store`  和 `this._push.store` 里面的数据都丢失了，那么在这里加一段兜底逻辑，也许就好了吧。

```diff
 if (this._push.store[key]) {
   return this._push.store[key];
 }
+ return this.failover(key);
```

当然，最终这个修复方案发布后，线上问题依然存在的，这个兜底逻辑完全没有进来。我也忙于业务开发，问题暂时搁置了。

### 第二次尝试

一直到三个月后，线上这个问题又重现了。我们接到营销那边报警，错误是活动 id 为空，找到对应的代码，活动 id 配置在凤蝶上。很明显，区块为空的问题重现了。

第一次修复的方式，后来想想，兜底失效还是因为 `defineProperty` 本身没执行，所以自然无法正在兜底了。关键还是在于，**没有定位到问题的真正原因**。

问题重现了，没有办法，只能继续硬着头皮上了，这次一定要定位到问题真正原因。如果实在不行，那只能对 `app.fengdie` 做一层代理了，找不到的 key 直接进入到兜底逻辑。

这个问题最难的点还是在于没有找到问题重现的步骤。

#### 日志分析

首先，只能从日志里面分析了。首先，基于有问题的 traceId 找到对应的 rpc 接口。然后查询了一下这个 rpc 接口的所有请求记录。这时候发现一个规律，同一个 rpc 请求，大部分都是正常的，有一小半的是报错了。通过肉眼可以看到，这些报错的请求，\*\*都是源自统一进程 id \*\*。

继续排查这个进程对于的请求，所有的请求都挂了。**这时候可以推断出，只有这一个进程是挂了的，其他三个进程是没问题的**。**这个进程的特殊之处在于，它是昨天启动的，那说明这个进程之前，有一个进程挂了，然后 chair 主进程重新启动了一个子进程**。

现在找到问题出现的一个必要条件，**进程挂掉，重启的时候可能会出现问题**。

#### 开发机调试

接下来我拉了一个新的测试迭代，部署了一台测试机。同时修改业务代码，让代码在特定请求下主动 throw 一个无法捕获的异常，主动挂掉进程。

```javascript
if (fengdieData.error) {
  setTimeout(() => {
    throw new Error('haha');
  }, 20);
}
```

这时候，请求接口，如果传递了一个 error 为 `key` 对象，接口就直接会导致进程重启。但试了几次，还是无法重现问题。看了很久，只能暂时再等等看了，线上场景是放一段时间，然后突然不行了，我也先放放吧。

**第二天早上，开发机真的重现问题了。难道，规律是重启后等一个晚上，内存数据就丢失了**？这里的逻辑还是不通，但已经好很多了，我感觉越来越接近真相了。

#### 增加日志

问题重现了，但是还是无法定位到原因。还得再加点日志才行，于是我把 egg-fengdie-pull 源码修改，增加了一批日志，发布了一个新的 egg-fengdie-pull-1 的包，在应用代码中强制指定使用新的测试包。

开发机重现构建部署，继续重复昨天的步骤。到第二天早上，尝试重启进程，问题果然重现了。这时候有了更多的日志，可以方便分析了。

```shell
2019-10-10 22:12:41,433 INFO 4577 [egg-fengdie-pull] defineProperty fengdie.instrabem_sample from Pull
2019-10-10 22:12:41,458 INFO 4577 [egg-fengdie-pull] init with config: {"ossRoot":"http://alipay-rmsdeploy-image.cn-hangzhou.alipay.aliyun-inc.com/filesync/dev/z/fengdie","configUrl":"http://alipay-rmsdeploy-image.cn-hangzhou.alipay.aliyun-inc.com/filesync/z/dev/insbffweb.json","backupDir":"/home/admin/run/fengdie_cache","pushRoot":"/home/admin/uiweb/fengdie","dumpRoot":"/home/admin/uiweb_dump/fengdie","cmsPushRoot":"/home/admin/uiweb","checkInterval":60000,"fileBackup":false,"dumpVm":false,"lru":null,"reportEndpoint":["https://filesync.alipay.com/report"],"pullUitpl":false,"uitplUrl":"https://gw-office.alipayobjects.com/basement_prod/3af05f6b-b0cc-4c88-9dda-e8de1c365caa.tgz","uitplRoot":"/home/admin/uiweb"}
2019-10-10 23:05:27,585 WARN 4577 [egg-fengdie-pull] [failover pull][insbffweb_common_fuse] async loading
2019-10-10 23:05:27,588 INFO 4577 [egg-fengdie-pull] read file from dumpRoot insbffweb_common_fuse
```

看到些日志，问题很明显了，**新重启的进程 4577 初始化的时候，只 defineProperty 了一个文件**。

上 basement 控制台看了下 `instrabem/sample` 这个文件，恰好在 22:12 这个时间发布过，问题重现的第二个步骤也找到了。

从日志上来看，**先发布一次区块，然后触发进程重启，这个新启动的进程就会异常**。

#### 问题重现

基于上面的猜测，服务器重启，然后发布一个区块，再触发进程重启，这时候果然文件丢失的问题重现了。

> 这里走过一次弯路，第一天重现的时候，当时有过猜测，是否和区块发布有关。当时还尝试过发布一个区块，但当时发布的那个区块并没有被 insbffweb 应用引用，自然不会触发区块更新，导致多排查了一整天。

找到问题重现的步骤，后面修复就简单了。

### 问题修复

egg-fengdie-pull 插件本身的运行原理大致是这样的

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS8xYTk4ODVkMS02NjI3LTRiNzItYjE4NS1jMzVhYzI0ZGUzOTYvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzODQwMCwiZXhwIjoxNzkwNTI0ODAwfQ.nnyIWASjFQmW5u1ldgR4JfXh-axVkYiPydtIBUZwhDg) 凤蝶区块会启动一个 agent 进程，用于负责文件相关操作，初始化文件读取，区块更新事件监听以及成功上报。

work 进程负责正在的请求逻辑，work 进程启动的时候，会监听 `pullConfigFiles` 事件，这个事件由 master 来控制，这里可以具体参考 egg 文档 [多进程模型和进程间通讯](https://eggjs.org/zh-cn/core/cluster-and-ipc.html) 。

问题出现在，当文件有更新的时候，事件 `pullConfigFiles` 的内容变成了 diff 后的数据。所以，这之后如果有进程重启，只能拿到最近一次发布的区块文件。

修复方式也很简单，把初始化和更新两部分换成两个不同的事件，这样进程重启的时候就不会被更新逻辑影响到了。

chair 官方有过处理这个问题，新版本的插件修复方式是绕过了这个问题。**进程重启的时候，由 work 主动拉所有的文件，而不是通过 agent 通知**。当时 chair 维护者也没有定位到真正的问题，只是通过换一种实现方案来解决问题。这种修改改动量非常大，所以插件维护者也不愿意对老版本进行修复了。

### 总结

凤蝶插件这个问题，本质上还是由于进程挂了，然后重启，但进程间通信导致数据丢失了。这种模式在本地开发环境非常难以测试，线上出现概率也不大。进程挂掉是非常严重的问题，线上还是非常少见的。

---

**Documents**

- [基于实践谈谈软件架构设计](https://iling.fun/s/blog/doc/5z65lqo5a6e6le16lci6lci6l2v5lu25p625p6e6k66k6h-tsGrQnNSNu)
- [rspack 使用体验过程](https://iling.fun/s/blog/doc/rspack-u8S4Mtjkfz)
- [E2E test with Playwright](https://iling.fun/s/blog/doc/e2e-test-with-playwright-JLFKAvWQ0U)
- [Vim everywhere](https://iling.fun/s/blog/doc/vim-everywhere-v6WZyLzGA1)
- [滴答 FaaS 研发平台小结](https://iling.fun/s/blog/doc/faas-XQxhEO3e5r)
- [Cod 服务编排之路](https://iling.fun/s/blog/doc/cod-0CFsOfiX20)
- [Cod 起源和用武之地](https://iling.fun/s/blog/doc/cod-1aAA2rVJZY)
- [2019 年度总结](https://iling.fun/s/blog/doc/2019-RhbTD2SiCn)
- [凤蝶区块历史及其问题排查记录](https://iling.fun/s/blog/doc/5yek6j225yy65z2x5y6g5yy5yk5yw26zeu6aky5o6s5pl6k6w5b2v-WoaaJ9QHpq)
- [如何用单元测试](https://iling.fun/s/blog/doc/5aac5l2v55so5y2v5ywd5rwl6kv-3zwG1cJvTn)
- [小钱袋 Node 应用开发经验总结](https://iling.fun/s/blog/doc/node-QpFfYCuoE0)
- [简单 JS 算数表达式求值算法的实现探索](https://iling.fun/s/blog/doc/js-FXcWkpW2vw)
- [凤蝶可视化编辑实现原理](https://iling.fun/s/blog/doc/5yek6j225yv6keg5yyw57yw6l6r5a6e546w5y6f55cg-ZsBj0CS1ev)
- [凤蝶服务端渲染经验总结二](https://iling.fun/s/blog/doc/5yek6j225pyn5yqh56uv5riy5pt57up6aqm5oc757ut5lqm-a4Kd59d7Td)
- [凤蝶服务端渲染经验总结](https://iling.fun/s/blog/doc/5yek6j225pyn5yqh56uv5riy5pt57up6aqm5oc757ut-GBhTESymbn)
- [Node.js 中 sourcemap 使用问题总结](https://iling.fun/s/blog/doc/nodejs-sourcemap-duex3wDhWt)
- [如何实现一个 mvvm 组件](https://iling.fun/s/blog/doc/mvvm-8TXAVSXe77)
- [webpack 热加载原理探索](https://iling.fun/s/blog/doc/webpack-7Wjn9z1fUB)
- [再谈 vim](https://iling.fun/s/blog/doc/vim-UBf5u57ko7)
- [How to realize velocity template interpreters](https://iling.fun/s/blog/doc/how-to-realize-velocity-template-interpreters-4s1KEznjYR)
- [joycss](https://iling.fun/s/blog/doc/joycss-bGvBvDGF7k)