- 发布日期
架构设计决定软件质量
- 作者

- Name
- BroZhong
引言
作为开发者,我们常常在一次又一次的事故中含泪体会墨菲定律的深刻含义 —— 只要一件事情可能出错,那么它就一定会出错。
接下来,我要讲述的这个故事,就是一个非常好的案例,一个简单的配置变更将我们的系统戳出三个大窟窿,笔者花费了整整一个下午的时间才把这窟窿堵上。这其中的 bug 很容易排查,但是却值得记录 —— 看看当初一些不假思索的决定是如何在系统中埋下陷阱的。
倘若我在架构设计、方案选择时多花一些时间思考,也许就不会花费一个下午的时间恢复数据...
脆弱的单体服务
我们公司有一个基于飞书开放平台搭建的用于短剧译制的内部系统,运营同学可通过这个系统做一些视频转码,台词翻译,短剧项目管理等工作。
这天,运营同学突然反馈项目管理的 web 页面一直处于无响应状态。我打开阿里云 K8s 容器服务,看到这个无状态部署已经重启了 10 来次了。一般来说,Pod 不断地崩溃然后被 Deployment 不停拉起就是遇到了很容易触发的 Panic。
果然,从崩溃日志中很快就定位到了这两行代码。
tableToken := strings.Split(strings.Split(link, "?table=")[0], "/base/")[1]
tableId := strings.Split(strings.Split(link, "?table=")[1], "&view=")[0]
这两行代码的含义是从飞书多维表格link(正常链接形如 https://xxx\.feishu\.cn/base/\{tableToken\}?table=\{tableId\}\&view=\{viewId\})中获取 tableToken 和 tableId。防御性编程意识比较强的同学都会在访问数组或者指针之前,先判断数组长度是否合法或是指针是否为空,而不是直接访问,更何况这里的 link 还是运营同学输入的。对于用户的输入不仅要判断技术上是否有风险,也要在业务上判断是否合法。
我看到这行代码时就反应过来了,这个肯定是链接不合法导致 split 出的数组访问越界了。同时,我在配置管理表上也发现了运营同学填写的不合法链接,这个问题就算是实锤了。
这个问题的发现和修复都很简单,真正可怕的是这个单体服务崩溃期间导致其他的业务也陷入了“异常状态”,比如被锁住的翻译表格,卡在中间状态的视频转码任务等等,下面我们从被锁住的翻译表格开始说起。
毫无必要的分布式锁
过了一会儿,运营同学又反馈在翻译表格上点击翻译按钮一直显示翻译中,但是一直没有翻译结果写入到表格中。我先去看代码梳理这个表格翻译的流程。
func Translate(*c* *gin.Context) {
sheetToken := c.Query("sheetToken")
isExist, _ := GetTranslateTask(c, sheetToken)
if isExist > 0 {
c.JSON(http.StatusOK, gin.H{"msg": "正在翻译中..."})
return
} else {
SetTranslateTask(c, sheetToken)
}
defer func() {
RemoveTranslateTask(c, sheetToken)
}()
// 翻译逻辑
}
从代码中可以看到,这里有一个上锁的操作,当前 Translate 的翻译逻辑在执行时,会通过这个表格的唯一标识 sheetToken进行上锁。翻译完成后,defer 函数会保证这个表锁被释放。看似没有问题,点到 GetTranslateTask 和 SetTranslateTask中就傻眼了 —— 这里竟然是用 redis 上的分布式锁?!
func SetTranslateTask(*ctx* context.Context, *sheetToken* string) error {
redisCli := driver.GetRedisClient()
key := fmt.Sprintf("translate-task:%s", sheetToken)
set, err := redisCli.SetNX(key, "1", 0).Result()
...
}
func GetTranslateTask(*ctx* context.Context, *sheetToken* string) (int, error) {
redisCli := driver.GetRedisClient()
key := fmt.Sprintf("translate-task:%s", sheetToken)
isExist, err := redisCli.Get(key).Int()
...
}
背过分布式锁八股文的同学都知道,使用 redis 做分布式锁时一定要考虑这几个因素
过期时间 —— 保证程序 Panic 后资源会被释放
Watch Dog 续约机制 —— 由于业务逻辑执行的时间不确定,锁的有效时间内业务逻辑不一定能执行完,所以续约机制也是必须的
这里我们就踩了第一个坑,没有设置过期时间导致资源被锁住不释放。修复方式也很简单直接,我们这个内部系统的流量根本就到不了需要水平扩展多实例承接的程度,单实例完全能应付,所以直接将分布式锁换成实例的内存锁即可。
无法自动恢复的任务状态
在我手动将 redis 中未被释放的锁删除之后,又接到反馈:视频转码任务已经卡在执行中好几个小时了,确认下任务为何卡住了。
下面,我先说明一下我们这个视频转码服务的流程。

运营同学会提交一系列的转码任务,任务会记录在飞书多维表格中
视频转码 Server 会轮训这个表格中未执行的表项,发起 K8s Job 并且标记任务处于执行中
视频转码是一个多阶段的任务,每个阶段需要的硬件资源有所差异,我们把它分为 Job 1,Job2,Job3
Job1 执行完会回调 API Server 的接口,API Server 发起 Job2 以此串联。同理,Job2 执行完成也会回调发起 Job3 。
当 Job3 执行完成时会标记飞书多维表格的任务状态为执行完成
上述流程在正常情况可以运行良好,将视频转码拆分为3种任务以 K8s Job 的形式执行也是合理的。一般而言,对于这种吃 CPU 和内存乃至 GPU 等硬件资源的任务都会封装成 Job 的形式离线异步执行,按需分配资源;如果将这些任务放在 Server 中,大批量的视频转码任务会直接将资源吃满。
但是,这里的异步解耦是不彻底的。虽然视频转码这个任务的整体异步出去了,但是其中的子任务之间仍然是同步调用,且依赖转码 Server 驱动整个流程。一旦 Server 崩溃了,视频转码就会卡在一个中间状态,即使服务重启后也无法自动恢复,需要手动干预。比如 Job1 调用 API 创建 Job2 失败,重试几次 API 调用后 Job1 就直接退出了,Job2 根本不会被发起,于是这个任务也就永远卡在了执行中的状态。
如果这里能引入一个消息队列进入异步解耦,那么即使转码 Server 崩溃了,服务恢复后无需人工介入也能驱动后续的流程。
反思与总结
**可维护性:**虽然内部系统不要求太高的可用性,但是当前这个系统一旦出问题会产生太多的异常数据,需要人为手动恢复,造成维护成本过高。架构设计一定要考虑可维护性的问题,得避免这种故障无法自动恢复的情况
**共享库导致的服务耦合:**从上述我的描述中也能发现,为什么视频转码,翻译,短剧项目管理等功能会耦合在一个服务呢?这明显是毫不相干的几个功能。其实,就是研发阶段为了图省事,这几个业务都涉及到调用飞书开放平台的 API,这个最初的项目中封装了这些 API 的调用,导致后续的服务服务都开发在这个项目中。虽然共享库常常成为服务的集成因素之一,但显然不适用于我们这种场景。我们这个共享库其实已经趋于稳定不怎么迭代了,根据业务拆分多个服务显然是更加合理的选择
**数据一致性的复杂度: **从上述故事中大家也能反应过来,运营同学没法直接感知到转码任务的运行状态,常常要问研发同学任务状态是否正常。之前发生发生过 K8s Job 执行失败,但是表格看板上仍然显示执行中的情况。因为 Server 的 DB 和飞书多维表格同时维护任务的状态信息,有时会出现任务执行失败但不能及时修改飞书多维表格记录的状态的情况,比如调用 API 遇到限流或者是飞书内部错误等等。公司内部不同数据库中维护数据一致性都颇有挑战,何况这里还依赖了第三方。所以,以后遇到这种存在多份数据的场景,思考一定要更加慎重一些,不得已不要引入数据一致性的复杂度。