我们网站是一个多域名的电商系统,有 3 个子站,但是共用一套购物车,添加购物车是在子域名,而下单是在主域名进行的,所以涉及到在 3 个域名下同步购物车的问题。这个场景可能在现实中不太常见,不过解决问题的过程还是挺有趣的,于是把几年来的一些经历做个记录。
这是最初使用的方案,所有用户信息的 cookie 都存在主域名,在子域名每次被打开的时候,用 ajax 请求来获取主域名的 cookie 信息,然后用这些数据来更新页面上的购物车商品数等等。用户在子域名下添加产品到购物车的请求,也是 ajax 发到主域名的。由于支付和账户页面都在主域名,子域名只需要很少的 session 信息,所以这种做法也没有什么问题。
到 16 年左右,为了用户隐私,各大浏览器开始禁用第三方 cookie。在 A 站向 S 站发送一个 ajax 请求,对于 A 站来说 S 站是第三方,这个请求对 S 站 cookie 的访问就会被阻断。所以 ajax 这个方案不能再用了,加购物车对主域名的读写都是无效的,用户跳转到主域名之后就会发现购物车根本没商品。
既然第三方 cookie 被禁用了,那只要把访问变成第一方访问就好了。我们采用的方式是,每当访问子域名时,如果当前 cookie 中没有一个 session_token,就用 307 跳转到主域名获取一个,写入子域名里。这样各站都能获得到一个统一的 session_token,再用这个 session_token 到 redis 里取用一个 session。可以看出这和 oauth 的流程挺像的,不过我们的系统需要在匿名的情况(允许不登录加购物车)下同步这个 session_token,不像 oauth 有个账户 id 之类的凭据,偶尔会出问题。
这里使用 307 可以让 POST 请求维持原样,302 则会变成 GET 请求。
一开始生成在子域名的 client_key 的作用是防止循环跳转,假如客户浏览器没有启用 cookie,即使 consume_auth_code 的请求回来了,下一次跳转还是会发现缺少 session_token,又会重新 generate_auth_code,陷入循环。而 consume_auth_code 请求中一旦发现 client_key 和子站域名里的不一致,就可以终止这个流程,并提示用户启用 cookie。
然而启用了这套系统,还是有零星的购物车产品丢失的情况出现。调查发现是两个 generate_auth_code 请求同时到达主域名,由于主域名 cookie 里还没 session_token,所以两个请求都生成了 session_token,写入 cookie 然后返回。其中有一个会失效,子域名也会因此得到不同步的 session_token。
当时我一直觉得这个情况是无解的,因为需要加锁,但是对两个匿名请求没办法加锁。有一天在网上聊到这个问题,有位网友才提醒我这种情况可以用乐观锁的,若当时没办法区分保留哪一个,就两个都保留,事后再统一选择一个。
于是我给 cookie 里的 session_token 键加上一个时间戳,如 session_token.1511254333304564304,这样一个域名下可能出现多个 session_token,但是后端总是使用时间戳最大的,然后把其他 token 都在 redis 里重定向到这个选定的 token,这样就解决了不一致的问题。
苹果在 safari 11 里引入了一套新的机制,Intelligent Tracking Prevention,对跨站 cookie 的访问阻断更严格了。ITP 会通过机器学习来阻断 cookie 访问,另外 307 跳转也被归到了第三方 cookie 的范畴,还有一些玄学的判定条件,例如用户如果没在 24 小时内访问 S 站,那么 A 站到 S 站的 307 跳转是访问不到 S 站的 cookie 的。
这个机制也一度让我们一筹莫展,甚至开始考虑仿照 google analytics 的 auto linker,在 url 里传递 session_token,然后应用浏览器指纹和 ip 来对某个客户端同步 session。当然这是很危险的,因为用户的 token 要暴露到 url 中,可能会被利用,而且手机的浏览器指纹重合率很高,最终我们也没这么干。
最后我们死马当活马医,既然 307 被拦截,那 200 总行了吧?我们尝试了用meta refresh进行跳转,并且意外的发现是可行的,cookie 访问没有被 ITP 拦截。于是 safari 11 这个问题暂时这么解决了,用 js 来跳转应该也是可行的。
即使这样,还是有零星的 session 不同步,原因五花八门,有的用户干脆禁用了我们主域名的 cookie,有的单独清了主域名的 cookie(主域名的 token10 年过期,子域名都是 session 级别的)。最奇葩的是 firefox,部分 firefox 浏览器会无端延长 cookie 的生命,一个 session 级 cookie 甚至能活一个月之久,理论上关浏览器就应该消失的。而且还会出现单个域名下,有多套 cookie 轮换,例如用户需要跳转到 paypal 支付,一分钟后跳转回来的时候却是另一套 cookie 了。
于是,我们也不得不将方案逐渐改为单域名。目前,我们让所有域名都可以进行下单流程,不依赖主域名,至少这样能让用户下单当前站的商品。同时在 url 里带一部分 session_token,可以及时发现 session 不一致,并且提示用户重开浏览器或清空 cookie 试试。
总的来说,这种多域名的方案已经不再推荐,如果有新的站点想这么干,还是及早悬崖勒马为好:-)。