故障排查系列:记一次线上图片 401 问题排查事件

983 阅读3分钟

问题描述

一个很小的图片预览功能,在列表里,鼠标浮上去显示 popover,鼠标划走消失,demo 示意图如下:

<div className='cursor-a' onMouseEnter={handlePopoverOpen} onBlur={handlePopoverClose} onMouseLeave={handlePopoverClose}>

....
// popover 开关
const open = Boolean(anchorEl);

const handlePopoverOpen = (event) => {
  setAnchorEl(event.currentTarget);
};

image.png

但是在线上灰度环境测试时,偶尔会出现如下的问题:

image.png

图床用的是公司自己的对象存储,每次 hover 打开 popover 后,调用后台 API 获取对象存储的访问 url,然后放到img 的 src 中,这个 url 有 3 秒的时效,超过就401。但是鼠标划上去的动作只有几毫秒,按理说不会存在过期的问题呀。

问题排查

首先怀疑是后端问题[坏笑],会不会是后台获取图床 url 时留有延时,导致压力测试下某一次请求延迟时间超过了 3 s?

打开控制台,找到出现 401 的那次请求,看请求后台的接口:

image.png

找到对应的base64编码,解码看一下过期时间:

image.png

解析一下看看:

image.png

再看看这次 401 请求的地址(src的地址):

image.png

解析一下过期时间:

image.png

一个 11 秒,一个 14 秒,啊这... 冤枉后端老哥了。是前端请求图片时的 src 用了过去老的 url 了。

不敢吭声.jpeg


重新看了一下,前端组件初次加载没有问题,多次反复加载图片弹窗就会出现 401 的出现。

然后,在弹窗中打印显示这个图片的 url,理论上应该是在初始化加载时是 undefined 的,通过 API 获取后 setInfo,通过 info 里的信息获取 url 给到 img 标签。这里初步排查发现是弹窗中图片的 url 在弹窗销毁时,数据没有清理,导致再次挂载弹窗组件时,在接口请求之前还是会用原来的 url (这里的字段叫 MediaFile) 来请求一次。

美滋滋的处理一下:

const handlePopoverClose = () => {
  setAnchorEl(null);
  // 清理数据
  setInfo({
    ...info,
    MediaFile: ''
  })
};

于是满怀信心的开始自测,刚开始确实没有出现 401 过期的问题,但是当鼠标不停划过表格各行的对应位置时,还是出现了 401 的问题.... 看来问题不止这一点。

只能继续排查。

期间为了尝试减少其他 props 变动造成的影响,在图片显示的时候,控制单一变量 info.MediaFile, 做了如下缓存:

const ImageShow = useMemo(() => {
    if (info.MediaFile) {
      return <img
        alt="preview"
        style={{ maxWidth: '80%', height: 'auto', display: 'block', borderRadius: '15px', marginBottom: '4px' }}
        src={`${info.MediaFile}`}
        onError={console.error}
      />
    }

    return null;
}, [info.MediaFile]);

发现还是有问题。看来还是 MediaFile 自身的问题。

最后发现是异步请求的问题😂,下面是 API 获取预览结果的代码示意:

useEffect(() => {
    if (open && TemplateId) {
      setLoading(true);
      setTimeout(() => {
        QueryMMSTemplate({ TemplateIds: [TemplateId], AccountId })
          .then((res) => {
            if (res && res.Data && res.Data.length && open) {
              console.log('这个时候可能页面已经关闭了')
              setInfo(res.Data[0] || {});
            } else {
              setInfo({});
            }
  
            setLoading(false);
          })
          .catch(() => {
            setInfo({});
            setLoading(false);
          });
      });
    }
}, [open, TemplateId, AccountId]);

异步请求导致返回结果设置 info 不可控,如果请求回来的慢了,刚刚清理数据的 setInfo 就会被覆盖掉,导致 MediaFile 没有被清理掉。但是之前也考虑了这个问题,加了这么一行:

if (res && res.Data && res.Data.length && open) {}

现在发现,这个里边是个闭包,open 可能一直是 true,即使在接口请求过程中鼠标划走....

于是做一下优化,使用外部 ref:

const openRef = useRef();
...
 
const handlePopoverOpen = (event) => {
    openRef.current = true;
    setAnchorEl(event.currentTarget);
};

const handlePopoverClose = () => {
    openRef.current = false;  // 加上这样一行
    setAnchorEl(null);
    setInfo({
      ...info,
      MediaFile: ''
    })
};

在 API 请求返回的判断里改写判断条件:

if (res && res.Data && res.Data.length && openRef.current)

此时再进测试环境测试,发现原来 401 的问题解决了!url 保证每次是最新的即可。

体验优化

在故障解决后,要回归测试,保证原功能没问题的前提下解决新的故障。

测试发现,虽然原故障解决了,但是这个解决方案又引入了新的问题:在关闭 popover 动画执行前,图片url 被手动清空,导致弹窗中图片会突然消失一下,视觉上会造成突变。这里给出两种解决方案:

  1. 无图片或者图片加载时提供模糊展示
filter: `blur(${(!img && !imgUrl) ? '5px' : 0})`
  1. 用一个占位图片表示

还有个问题,如何API节流,用户不停的在列表上划来划去,API 预览接口一直在调用,造成网络资源浪费。 :

setLoading(true)
setTimeout(() => {
    if (openRef.current) {
      QueryMMSTemplate({ TemplateIds: [TemplateId], AccountId })
      ...
    }
}, 200);

这里在请求 API 时添加加载冷静期,并在 loading 时放上加载动画:

{loading ? (
    <CircularProgress />
) : (
    <Review forPopover value={info.Text} ... imgUrl={info.MediaFile} />
)}

复盘

该问题的出现,在于写作时没有注意数据清理,导致老的数据干扰了正常数据。

该问题的避免,一方面是通过代码审查和单元测试提高代码质量,一方面是靠 QA 的充分测试。但是测试的再充分,还是百密一疏。还好该问题的出现是隐式的报错,不会干扰用户的使用。

此外,还要有一套规范的纠错机制,总结一下流程:

graph TD
出现错误 --> 技术支持反馈
--> 技术负责人分析并划分责任人
--> 责任人纠错 --> 补充单元测试
--> QA回归 --> 灰度测试 --> BugFix发布

技术负责人分析并划分责任人 --> 记录问题
责任人纠错 --> 分析原因

记录问题 --> 分析原因 --> 问题归档-可追溯