背景
昨天下午上线了 gas-admin 的 node 服务,今天上午 QA 在回归时发现一个问题,表现为,点击退出登录后,页面反复刷新,无法正常进入网站。
此时,赶紧采取降级策略, 将 web 页面调用的 node 接口,改为调用原后端接口。(代码改动量上,就是调用接口地址字符串的 v1 改为 v2,当然前端有接入配置中心的话,直接在配置中心修改会更方便)
做服务应用时,降级思维是多么重要!新服务上线或其他大的改动时,一旦出现问题,如果没有降级和回退方案,开发者很容易陷入手忙脚乱中。
登录流程梳理
切回原后端接口后,网站恢复正常了,接下来就可以慢慢排查了。
先分析一波登录流程。
我们的登录是借助 soup 中心鉴权服务接入 google Oauth 登录的,登录流程如下:

代码上,我们编写了一个 login 中间件 来,进行每次请求的登录鉴权:
export class LoginMiddleware implements NestMiddleware {
async use(req: IRequest, res: Response, next: NextFunction) {
const token = getCookie(req, SOUP_COOKIE_NAME);
const userInfo = getCookie(req, 'user_info');
if (!token) {
logger.info('without token----');
const createSessionResponse = await createSession(SOUP_TOKEN, CID);
const session = createSessionResponse.data as string;
const redirectUrl = getRedirectUrl(session, nextUrl);
logger.info('redirectUrl--------', redirectUrl);
logger.info('setSoupCookie', session, SOUP_COOKIE_EXPIRES);
setCookie(res, {
key: SOUP_COOKIE_NAME,
session,
expires: SOUP_COOKIE_EXPIRES
});
clearCookie(res, 'user_info');
logger.info(req, 'no login, redirect to soup_login');
throw new BaseException(
HttpStatus.UNAUTHORIZED,
BusinessCode.TEMP_REDIRECT,
{
login_url: redirectUrl
}
);
}
if (!userInfoStr || !userInfo) {
logger.info('with out userinfo----');
const ssoToken = getCookie(req, SOUP_COOKIE_NAME);
const rsp = await getSession(SOUP_TOKEN, CID, {
sso_key: ssoToken
});
logger.info('rsp -----', rsp);
// 判断getSession error或者非soup用户登陆,则跳转首页重新登陆
if (rsp?.data && Object.keys(rsp.data).length > 0) {
const { sso_key: ssoKey, user } = rsp.data as SoupGetSessionData;
const userIn = crypto(user, userCryptKey);
req.userInfo = user;
// 设置用户id供日志使用
req.uid = user.id;
logger.info(req, 'login success');
logger.info('setSoupCookie', ssoKey, SOUP_COOKIE_EXPIRES);
setCookie(res, {
key: SOUP_COOKIE_NAME,
session: ssoKey,
expires: SOUP_COOKIE_EXPIRES
});
res.cookie('user_info', userIn, {
expires: new Date(Date.now() + SOUP_COOKIE_EXPIRES * 1000)
});
} else {
logger.info('clear cookie ------');
clearCookie(res, SOUP_COOKIE_NAME);
logger.error(req, 'get session error or no internal user');
throw new BaseException(
HttpStatus.UNAUTHORIZED,
BusinessCode.TEMP_REDIRECT,
{
login_url: nextUrl
}
);
}
}
next();
}
}
总结下来,就是未登录状态下或登录态 token(cookie)过期后,
- node 会先调用 soup 生成一个临时 token 的 cookie,并清除掉 userInfo cookie;
- 跳转到谷歌登录页,用户点击登录,跳回网站,重新请求接口;
- userInfo cookie 已被清除,node 去 soup 查询 session 数据
- 若能拿到用户信息和常驻 token,种入 cookie;
- 若不能拿到,则清掉 token 信息,跳转回网站首页,重新走第一步;
尝试本地复现,观察日志:

可以看到,连续走到了 clear cookie ,即上述流程的第 3 步的第 2 分支。
抓取网络请求,会看到有连续接口的请求,点开后会看到每个接口请求里都带有 token cookie,这时,我们就怀疑没有清掉 token cookie,与上述 node 日志的现象吻合;
未进一步验证,在本地跳转前断点,观察此时浏览器 cookie:

确实,没有删除掉 token 的 cookie。
而且,观察到此时 token cookie 的 domain 前带有一个 “点” 的前缀。是不是就是这里的问题呢?
再回到清除 cookie 的代码实现:
export function clearCookie(res: Response, key: string) {
res.cookie(key, '', {
expires: new Date(Date.now() - 60 * 1000),
});
res.cookie(key, '', {
expires: new Date(Date.now() - 60 * 1000),
path: commonConfig.PUBLIC_API_PATH,
});
}
可以看到,是将某 cookie 的值设为空字符串且即刻过期的方式,为了验证,我们设置稍后过期。这时观察浏览器的 cookie:

清楚的看到,node 设置的 token cookie 并没有覆盖之前的,且 node 设置的 token cookie 是不带“点”前缀的。
这下真相大白,因为 domain 不同,所以无法覆盖(即清除 token cookie)。
这与测试环境和 uat 环境没有复现,只在 live 环境上出现的现象不谋而合。live 环境上是原后端 go 服务设置的域为 .mkt-admin.shopee.sg 的 cookie, 刚上线的 node 服务清除 cookie 的逻辑未显式指明域,默认是不带点的域,这时就无法清除 token cookie,导致不断返回未登录,前端页面无限刷新的现象。
总结
这次解决 bug 的过程告诉我们:
- 敬畏线上环境,发布有降级方案;
- 复现后会降低排查解决的难度(这次因为 test、uat 环境无法复现,且 live 环境已回滚。最后通过 whistle 配置 live 的域名到本地才得以复现)
- 冷静分析,信心观察(后端观察日志,前端观察请求,资源状态等);
- 对自己的代码要有把控力,对复杂逻辑要通过梳理流程图等存档;