我在字节的这两年

20,014 阅读24分钟

前言

作为脉脉和前端技术社区的活跃分子,我比较幸运的有了诸多面试机会并最终一路升级打怪如愿来到了这里。正式入职时间为2021年1月4日,也就是元旦后的第一个工作日。对于这一天,我印象深刻。踩着2020年的尾巴接到offer,属实是过了一个快乐的元旦。不知不觉已经两年多了,细细回想起来,更多的是岁月推移,并没有回头看看现在的自己和两年前的自己有什么差别。

决定写文章记录一下还要感谢那个离职前在飞书上和我告别的老哥,他说已经学到了想学的

那我呢?似乎还没有。

和优秀的人做有挑战的事不止是简单的一句话。

在字节停留时间越久,越是能感觉到身边人的优秀,也正是这份优秀推动着我不断前进。

本文将会从思维方式、问题排查、技术思考三个方面以回顾自我成长的视角展开叙述,欢迎阅读。

思维方式

思维方式指的是看待事物的角度、方式和方法。放到工作当中来看,我逐渐摸索出了几个具体的点。

工作优先级

曾很长一段时间里,我在工作上没有刻意区分优先级或者说有优先级但是区分度不是那么明显。这意味着只要不是恰好有紧急事情处理,基本上业务方提过来的合理需求我都会第一时间安排。不论需求大小,也不问紧急程度,都默认当作紧急处理。

诚然,在交付后得到业务方肯定的那一刻是有成就感的。但我逐渐意识到,这真的是有点本末倒置。由于我负责的这部分工作和底层数据相关,可能很多需求直接或间接的都会找到我。事实上,完成对齐过的工作才是我更应该高优做的事,剩下时间用来完成这些零散需求才更为合理。

起初我觉得有些小需求可能就是一两行代码的事,顺手一个分支就带上去了。但仔细想想,这好像引发了蝴蝶效应。一件事仅仅完成是不够的,该有的环节要有。 开发,测试,上线,周知业务方验收。这样一个小流程走下来耗费的时间可不仅仅是一两行代码占用的时间可比。更何况,可能还不止一个零散需求。时不时被打断,自然就会导致原有工作安排非预期delay。

在意识到这个问题后,来自业务方的需求我会主动问一下优先级。如果不是特别紧急的事情将不会安排在当前周期的工作计划里。此外,优先级判定上我会和业务方确认完使用场景后有自己的思考。对接次数多了,发现有些紧急并不是真的紧急,只是单纯的性子急。后来,对于这种零散需求,我会在项目管理平台写好描述和需求提出人,方便后续沟通。

这个记录还是很有意义的,深感好处明显。

  • 可以起到一个备忘录的作用,定期查看,提醒自己有todo要处理
  • 业务方(需求提出人)可能因业务场景变更或有了其他解决方案,不再需要后续支持
  • 原业务方(需求提出人)转岗或离职,不再需要后续支持

等到决定去做的时候,如果发现时间间隔较久,不要急着写代码,先和业务方二次确认这个需求是否有必要继续做。试想,如果耗时耗力做完,最后邀请业务方验收时候对方又反馈用不到了。什么心情?那肯定满脸黑人问号啊?实惨如我,曾有过这样的经历。深感前置确认真的很有必要,这样能有效避免打黑工的场景。

在有意识对工作优先级进行划分后,原定对齐的工作进展基本都可以得到保障。等到工作周期结束进行总结的时候,看到比较高的完成度,我觉得这份成就感更高。

ROI考量

ROI 全称为 Return On Investment,指的是投资回报率。我是在完成一个比较重要的功能模块迁移后才更加认识到这个东西的重要性。在做数据迁移的时候,我写脚本进行的全量迁移。为了兼容新旧平台的格式差异,我做了好几处的格式转换,过程中还遇到好几个bad case需要手动处理,总之并不是那么顺利。等到一切准备就绪,我开始拉群周知用户并以表格形式逐个进行使用情况的回访。结果很尴尬,实际使用的用户远低于历史存量用户。量少到我完全可以采用更快的手动迁移,省去做格式转换和写脚本的时间。

对于那些实际没人用的数据,我后来又进行了删除处理。这一波操作下来,真的投入产出比就不高了。算是吃一堑长一智吧,在对一个功能模块进行迁移的时候,前置工作除了搞清楚历史背景,实现原理,更应该确定实际使用人群。尤其是对于一个存在年头比我入职时间还久的功能,更应该花时间在这个点上好好调研下。确定目标人群才好"对症下药",这样才有可能是多人的狂欢而非仅仅是一个人单纯完成迁移工作的孤独玩耍。

有心和无意真的是两种不同的感觉。 实际上,在经历这个事情之前我对自己研发的模块也会有很多想法。有较长一段时间里,我脑海中冒出来的小想法会连同某个分支功能带上去,改动不大,但是可能要思考的点会比较多。现在回想起来,大多数属于ROI比较低的。而现在,不论是业务方提出的需求还是我自己的小想法我都会优先考虑ROI的问题。时间是很宝贵的,在有限时间内产生更高价值带来的成就感和自我认同感绝对是翻倍的。

技术与业务关联

在来字节前,我很喜欢花大把的时间去钻研一些自己喜欢但可能实际未必会用到或者说使用场景比较局限的东西。比如我曾跟着视频教程鼓捣过一段时间的Angular 1.x 。当时觉得ng-xx这个指令写起来倍感新奇,有种发现新大陆的小激动。也曾跟风学过一段时间的php,被其数量庞大的内置函数所震惊。等转回到业务上,发现花费大量时间研究的东西和业务根本不沾边或者说没必要为了尝试而去强切技术栈。如此一来,割裂就产生了。我曾好长一段时间困在这个技术和业务二选一的局面走不出来。

等入职字节并工作了一段时间后,我发现当业务形态开始变得复杂,对技术的考验也会随之而来善于运用技术恰到好处地解决业务痛点,远远比单纯研究技术有意义。 自嗨终究是自嗨,没有实际落地场景,过一段时间就会忘记。如果还没想清楚技术服务于业务这个关键点,那就会陷入【钻研技术->长久不用->遗忘->钻研技术】这个循环。保持技术热情是好事,但是对于一个几乎没有业务落地场景的技术,投入大把时间研究又有什么用呢?知识是检索的,当需要时自然会朝着这个方向靠近,有具体落地场景才能更好地巩固。

进一步让我体会到技术与业务是相辅相成的契机是对图数据库bytegraph的相关技术调研和最终的投入使用。业务场景需要,我这边会涉及不同类型数据之间关联关系的管理(CRUD操作)。这个关联有层级的概念,全部关联建立数据量已到千万级别。从设计角度和实践角度综合考量,已经不是MySQL擅长的场景。细想一下,层层关联铺开不就是一张图吗?自然是图数据库存储更为合适。

在我看完bytegraph相关文档并使用Gremlin图数据库语言写了几个符合自我预期的基础语句后,突然又找回了曾经独自钻研技术的快乐。在使用过程中,很自然的就和业务关联起来了。比如如何设计点和边?如何提高关联图查询速度?我曾写过一篇关于图数据库bytegraph介绍和基本使用的文档,有同学在看过后就着某个具体业务场景下点该如何设计这个话题和我进行了语音交流,最后我结合实际使用场景给出了有效结论,被肯定的瞬间同样是成就感满满。此外,在工作中对bytegraph的使用诉求,还推动了bytegraph NodeJS SDK 的诞生。有幸成为第一个吃螃蟹的人,真的很有纪念意义。

寻求长期方案

很多时候,解决问题的方案都不止一个。绝大多数情况下,选择临时解决方案是最快最省力的。当然,也不排除某些极限情况下足够的临时趋近于长久。但临时终归是临时,这意味着中后期规划可能会有变更,从而导致现有的方案不再适用,所以说寻求长期稳定的解决方案才是最终目的。尤其是当系统稳定性和切换成本冲突时,更应攻坚克难去破局。近期完成了权限平台相关接口的升级替换,由于历史包袱沉重,旧的权限接口越来越不稳定,已经影响平台侧权限的正常使用。在这种情况下,真的是不得不换。好处还是很明显的,虽然过程艰难,但稳定性上确实得到了保障。

相信字节内很多平台都是对权限系统强依赖的,这意味着一旦权限系统服务出了问题,其他的下游服务都会受牵连。这种权限问题感知相当明显,最简单的一个例子:为什么自己创建的东西在操作时提示没权限?

为了降低权限系统不可用对自身业务的影响,我用redis对所有涉及权限读数据的地方做了缓存(如用户权限列表)。每次刷新页面会在获取用户信息的同时查询最新的权限信息,当检测到返回结构非预期时,则不再更新,直接返回缓存数据。一般来说,读权限场景比写权限场景更多,有这样一层缓存来兜底,还是很有价值的。

此外,为了避免自己创建的东西在操作时提示没权限的尴尬局面,我进行了业务自身数据库优先权限系统接口查询的处理。这个很好理解,写到自己数据库再读取往往比写到权限系统数据库再读取来的方便,后者可能会有延迟。完成整体权限系统接口升级替换,再结合redis缓存,数据库优先权限系统接口读取这两个策略,在业务侧整体权限稳定性上可以看作是一个长期稳定的方案了。

直面问题

对于一个开发来说,出现问题在所难免。解决问题固然重要,但是摆正心态也同样重要。工作中基本都是多人协作开发,当收到线上报警消息时,如果能确定和自己的某些操作有关应及时和相关同学说明,避免其他人一同跟着排查。有句话听起来很矛盾,但是语境还挺合适的:"我知道你很慌,但是先别慌。" 出现问题,排查清楚后,及时修复就好,切莫讳疾忌医。

此外,有些问题隐藏比较深,复现链路较为隐晦,甚至可能除了开发自身,其他人几乎不会有感知。我曾遇到过一个这样的case,代码写完过了一年,也没有人反馈,最后还是我自己在某次调试时候发现并修复的。随着编码经验的积累,思维发散性也会更广,不同阶段考虑的点自然也有差异。没必要过多纠结当时为什么没有考虑到这个场景,更应该思量的是下次遇到类似情况如何避免。亡羊补牢,为时未晚。

问题排查

问题排查可以说是一个开发人员必备的能力。个人感觉保证开发永远不出bug的方式就是不去开发。当然,这并不现实。在字节这两年多的时间里,我踩过好多的坑,也出过事故,逐渐摸索出了一些问题排查的经验。

环境一致性校验

工作中我这边常用到的是本地环境、测试环境(boe),生产预览环境(ppe)和正式生产环境(prod)。每个阶段都有可能会引发问题,在开始排查问题前,需要先确定自己的调试环境与引发问题的环境一致。乍一看可能感觉这句话是废话,但是有过相关经验的人都知道这一条真的很重要。

说来惭愧,我有过本地调试半天发现死活不生效最后意识到看的是生产环境页面的尴尬经历,真的是又气又无奈。

优先保证这一点,能少走很多弯路。

格式一致性校验

格式一致性校验指的是确认原始数据在有意格式处理或漏处理后,是否和后续程序要接收的数据格式保持一致。

一般来说,编码粗心或者测试不够充分都有可能引发格式相关的问题。

有意处理的场景:

const list=[1,2,3]
// 有意处理
const formatList =list.map(d=>({
    id:d
}))
// 省略一大段代码

// 此处错误传入了list,应使用formatList
getData(list)

function getData(list){
 // do something...
 return xxx
}

在前端操纵数据store也有可能存在类似的问题,原始数据格式在某个组件里被修改导致另一个组件无法预期解析。

漏处理的场景:

// sequelize findAll查询 限定只返回id属性
const ids = await modelA.findAll({
  attributes: ['id'],
});

await modelB.findAll({
  where: {
    id: ids,//这里漏掉了对ids的处理 
  },
});

如图,使用了sequelize model方法中的findAll查询并限定只返回id属性,且变量命名为ids。

实际上,返回的结构是对象数组{id:number}[],而不是数字数组number[]。

请求响应一致性校验

服务里定义的路由地址和前端请求时的地址对不上,导致请求404。

可能是因为单词拼写错误:username or ursename? cornjob or cronjob? 或者cv后没有改全。

前置条件确认

这个偏向于涉及事件触发的场景,要先满足其前置条件。

下面列举几个有代表性的场景:

  1. 如果想在群里接收某个机器人推送的消息,需要先把机器人拉进群
  2. 如果想在eventbus消费生产者产生的数据,需要确保消费者是开启状态
  3. 如果想使用sdk正常解析hive数据,需要先申请表权限

分区间排查

这种方式适用于排查由程序代码引起但尚不确定具体代码位置的场景。

我将其划分为三段式:

  1. 给怀疑会出问题的代码圈定一个区间,非怀疑区间代码直接注释(前端更有效)或return掉(后端更有效)
  2. 添加相关打印并重新运行程序,观测输出和程序运行结果是否符合预期
  3. 收缩区间,重复1,2步骤,直至发现问题

这里举一个我在使用bytegraph过程中亲身遇到的一个cpu暴涨的例子。

最初bytegraph并不支持全图查询,所以在获取某个点所在的整张关联图谱时拆分成了以下三个步骤:

  1. 查询某个点在整张图上的关联点
  2. 遍历每个点,查询入边和出边
  3. 根据边的指向拼出完整的图谱

伪代码如下:

function getGraph(vertex:Vertex){
    // 查询某个点在整张图上的关联点
    const nodes=await getNodes(vertex);
    console.log('get nodes')
    // return  分割区间一,后续直接return
    // 遍历每个点,查询入边和出边。
    const edges=await getEdges(nodes)
    console.log('get edges')
    // return  分割区间二,后续直接return 
    // ... other
}

async function getEdges(vertexs: Vertex[]) {
  let res: any = [];
  for (let i = 0; i < vertexs.length; i++) {
    const vertex = vertexs[i];
    // 根据点查询入边和出边
    const itemEdges=await findEdge(vertex);
    res = [ ... res, ... itemEdges];
  }
  // return res 分割区间三,不执行uniqWith返回res
  // 深度去重
  return uniqWith(res, isEqual);
}

采用分区间排查问题的思路,在关键节点添加打印日志,触发调试。

查看打印信息,发现每次都是在获取所有边那里卡住。

此时可以进到getEdges里边查看,发现内部有一个去重操作。

试着去掉这个过程,再重试,问题未复现。ok,定位问题。


针对这个问题,我写了一个可复现的最小demo,感兴趣的可自行尝试。

结论是lodash的uniqWith和isEqual方法对大数据 重复率不高的数据进行深度去重会导致cpu暴涨。

const { uniqWith, isEqual } = require('lodash');
const http = require('http');
http
  .createServer(async (req, res) => {
    const arr = [];
    for (let i = 0; i < 10000; i++) {
      arr.push({
        n: Math.random() * 20000,
        m: Math.random() * 20000,
      });
    }
    console.log(uniqWith(arr, isEqual));
    res.end('hello world');
  })
  .listen(3000);

请求溯源

对于有提供Open API 给其他业务方使用或者说当前服务存在开放性接口(未设置权限)的情况下,都有可能存在非预期调用,其中最典型的是参数错误和session信息缺失。

我有过类似经历,某个已经线上稳定运行过一段时间的接口突然开始报错,从错误信息来看是参数错误。随后我仔细查找了代码里的调用点,只有可能在平台使用时触发。进一步查看,确认是开放性接口,没有权限管控。意识到应该是某个用户手动触发的,因为平台侧正常使用的请求参数符合预期。如果能定位到具体的人自然最好,如果找不到人就需要在代码层面做一个参数校验,如果传递过来的参数不符合预期,直接return掉。类似的,平台侧调用一定可以拿到session信息,但是接连几次报错都是拿不到session导致的,怀疑是非常规调用,直接return。

安全日志记录

我负责的工作中涉及很多底层数据,这些数据属性变更有可能会引发非预期的安全卡点。开启卡点的资产越多,类似问题感知就会越明显。内部定时任务,外部平台配置变更,扫描任务,人工变更都可以导致资产属性发生变化。因此,究竟是哪一环节发生的变更显得尤为重要,这能有效缩短问题排查链路。

通过在每个变更节点添加一条安全日志记录,可以有效辅助排查。此外,还可以作为业务方溯源的一个途径。比如解答某个资产卡点什么时候开启的?卡点开启同步自哪个部门?

审查数据库字段

在某些业务场景里会在数据库中存储JSON 字符串,此时需要对实际可能的JSON大小做一个预判,之后再设定与之匹配的字段类型和数据大小。否则当实际长度超过数据库设定字段长度时,JSON字符串就会被截断,导致最后的解析环节出错。

超时归因

开发中遇到网络超时问题太常见了,大多数情况下都可以通过添加重试机制,延长timeout的方式解决。这里我想说的是一个比较特别的场景,海外,国内跨机房通信。 绝大多数海外和国内的通信都是存在区域隔离的,调用不通表现上可能就是网络超时,这种情况下,重试也没用。解决途径也比较直观,要么直接避免这种情况,海外调海外,国内调国内,要么申请豁免。

善用工具

argos观测诊断平台

在问题排查上,观测诊断平台能起到有效的辅助作用。除了报错日志,还可以看到所在服务psm,集群,机房。这些都是缩短问题排查链路的有效信息,在服务实例比较多的情况下表现尤为明显。此外,还可以配置报警规则,命中后会有报警机器人进行推送,可及时感知线上问题的发生。

飞书机器人

真心觉得飞书机器人是一个很好用的小东西。用它可以干很多事,比如按时提醒该喝水了。在报警感知上,也可以通过机器人搞点事情。例如在某个装饰器里对核心接口请求地址(如包含/core/)进行识别,随后在catch代码块里捕获错误,最后将error message or error stack 推送到指定的飞书群里,这样团队其他成员也能及时感知。

飞书表格

个人精力有限,不可能时时刻刻盯着报警信息其他什么都不干。对于一些看起来影响不大,不用紧急修复的报警可以先通过飞书表格记录下来,等有时间后当成待办事项逐一解决。亲测,这种先收集后集中处理的方式比发现一个处理一个更省时间。

技术思考

规范

很长一段时间里我对技术的理解是运用掌握的知识完成开发,仅此而已。但事实上,开发流程不应仅局限于开发环节,还有其他很多有价值的事情需要关注,比如一些规范。团队协作和独立开发还是有明显区别的,没有规矩不成方圆。既然是协作,就要有达成一致的规范。

我曾写过一篇关于lint的文章并在小组内和其他同事对齐,共同商讨缩进风格,哪些规则要开启,哪些规则要禁用。项目编码风格统一的管控实现上依赖husky和lint-staged,在提交代码时进行lint检测,不符合检测规则无法提交,这样可以有效避免个人编码风格差异导致的格式change。

在代码提交上,由组内另一个同学制定了git工作流规范,共同约定了不同功能分支如何命名,分支间如何检出与合并,commit 应该如何编写。这种规范形成文档后作用明显,不论是日常开发还是线上部署,都有了更清晰的操作流程。此外,见名知意的commit message也更有助于查找具体功能点。试想一下,如果简写一个fix,或fix err ,等过段时间再看,哪里还记得到底fix了个什么?

类似的,小组内还有需求迭代,上线部署等相关规范,这些规范站在开发的全局视角来看,都是很有价值的。

质量

研发质量问题是一个非常值得重视的点,开发完成并不意味着整个研发环节就结束了,质量过关才是最后的收尾节点。简单来说,上线后功能平稳运行,无bug和性能问题,这样才算是合格。虽说百密一疏,但反复踩同样的坑或者踩不应该踩的坑就有些说不过去了。我印象比较深刻的踩坑点在于数据格式处理,这个在上文报警排查处有提到,不再赘述。还有一点,对于跨越大版本的sdk升级,一定要认真且足够详细的审查是否存在break change。有些break change是比较隐晦的,乍一看可能察觉不到玄机,切记想当然,在项目代码中搜索看看,总比自我回忆要可信的多。想要收获一批忠实用户,研发质量一定是排位比较靠前的。

稳定性

这里特指研发的系统稳定性,初期我这边涉及到的系统架构比较简单,所有功能模块共用一个服务。这样好处是很多代码可以复用,开发和上线也比较方便。祸福相依,但是一旦服务崩溃,除了影响自身业务正常使用,还会朝着下游其他业务辐射。具体表现上来看,一般是OEPN API不可用。为避免类似问题再发生,我和小组内其他同事一起完成了服务架构升级,将不同子模块拆分成不同的服务,接口层面根据重要等级和业务类型并借助负载均衡能力,分散至各自所在服务的不同集群。架构升级完成后,即使某个子模块出现问题,也不至于牵动整个服务崩盘。在此次架构升级中更深刻体会到了不同类型数据库在特定场景下的使用,Redis,MySQL,MongoDB,bytegraph都有涉及,收获颇多。

文档先行

对于一些偏复杂的模块,先找个文档梳理一下,逐步拆解清楚后再开始编码,属于磨刀不误砍柴工。以前我的习惯是想一个大概,然后投入开发,写着写着发现之前想错了,然后删掉代码,再写新的,这个过程可能会反复好几次。冷静下来好好想想,真不如先写清楚文档更省时省力。实测,让思维在文档上交锋,远比在编辑器里打架轻松的多。

沉淀总结

我始终觉得,有输入就应该有输出。不论是日常基础搬砖,还是攻坚克难了某个业务痛点,又或者加深了自己对某项技术的理解,都应该有所展现。并不是说非要落笔成文,但至少应该在一个属于自己的小天地里留些痕迹。如果实在懒得打字,不妨试试拍照式记忆。亲测,这个是科学中带有点玄学的方法。

先找到想要记住的画面,可以是控制台的数据打印,也可以是bug调试截图,又或者某段关键代码,然后想一个主题,与之进行关联,重复思考几次。好的,记住了。

还是那句话,有心和无意是不一样的。有心留意,这份记忆就会更为深刻。当下次遇到类似场景,近乎是条件反射的思维反应。比如我现在每次写删除语句一定会检查是否加上了where条件。这是有特殊意义的一段经历,不堪回首。

落地统计

辛辛苦苦搬砖究竟产生了怎样的价值呢?究竟有哪些人在用?这同样是一个比较关键的点。我曾梳理了一个关于OPEN API 业务落地情况的表格,里边记载了哪些业务方在用,什么场景下会用,对接人是谁。这样除了价值考量,还可以在接口变更或下线时及时联系使用方,避免造成非预期的影响。

总结

不知不觉,洋洋洒洒写了几千字,梦回毕业论文。曾觉得自己属于有所成长,但是成长算不上快那种。写完这篇文章后再回首,竟也方方面面很多点。不错,经过一番努力,终于从一棵小葱茁壮成长为一棵参天大葱了。

回到最初的问题上,时至今日,我仍然觉得还有很多东西要学。距离把想学的都学到,大概还有很长一段路要走。

好在这一路不算孤独,能和身边优秀的人一起做有挑战的事。

前方的路,仍然值得期待。

完结,撒花!