vivo全球商城時光機 – 大型促銷活動保障利器

一、背景

官網商城在雙11、雙12等大促期間運營同學會精心設計許多給到用戶福利的促銷活動,當促銷活動花樣越來越多後就會涉及到很多的運營配置工作(如指定活動有效期,指定活動啟停狀態,指定活動參與商品等等)。

如果因為某些原因導致其中部分配置未按預期配置,等到大促那一刻才發現配置沒有正確配置,這樣大概率會流失不少訂單,同樣也可能會出現錯配優惠導致一些本不該享受的優惠也被用戶享受到,可能會給商城帶來比較大的損失,因此為了盡量減小前面這些情況的發生的概率,我們就想能不能提供一種能力,讓運營同學在重要的電商大促正式開始前,提前去校驗所有期待的優惠是否配置正確。

二、構思

想讓運營同學能去校驗所配的大促優惠是否正常,同時又希望不會增加多餘的額外工作,如何做到呢?

考慮到電商業務的特殊性,所配置的各種大促優惠最終主要都會體現在優惠後的價格上,因此我們考慮從這個角度去實現。

在電商的核心鏈路上,主要有商詳頁、購物車、確認訂單、提交訂單這幾個核心場景,那麼只需在這幾個場景中實現提前看到優惠後的價格即可判斷大促優惠是否配置正確。

那現在的關鍵問題是如何做到「提前」看到呢?在前序的促銷系列文章我們介紹了計價中心的建設,計價中心統一收口了所有的優惠價的計算能力,因此我們只要讓計價中心能提供「提前」的能力即可。

計價中心計算優惠價正常只會實時計算當前時間商品能夠享受的各種優惠,並將最終優惠價告訴上游業務方,所以我們能讓計價中心能夠計算「未來某個時間點」的優惠價即可,而計價中心在計算優惠價時,依賴的一個關鍵資訊是「當前時間」,因此我們只要將所謂的「當前時間」進行「篡改」變成未來的某個時間點,實現我們所謂的穿越的目的。

還有一個極為重要的點需要關注,也是這個穿越能力的大前提,就是不能影響線上正常交易,即不能讓正常的普通用戶也「提前」看到未來的優惠價。

因此如何做到既讓運營體驗又不影響正常用戶呢?我們考慮採用白名單機制,只針對已登錄且用戶id在白名單中的用戶才能進行所謂的穿越體驗。

在確定大體思路後,還有一些問題需要確認:

對於穿越的完整體驗是否只需要商城購物流程?

如果需體驗大促期間整個官網商城的所有氛圍,可能涉及改動的點較為多,比如大促宣傳活動頁面、專屬聚合類商品頁面,簡化版的只關注整個購物下單流程。

整個穿越過程是否需要真的要真實創建訂單?

由於穿越時光後,用戶的下單時間和確認訂單的時間是一致的,因此確認訂單頁的所有優惠及最終的價格是真正的所見即所得,無需真實下單即可獲知所有優惠活動資訊

所以在提交訂單的時候建議直接阻斷並提醒用戶「您當前處於時空穿越,請回到現實中再下單哦」,並不作真正的創建訂單,也就不會作後續許多寫資源的連鎖操作,同時這種情況下也會減少很多不必要的改動點。

對於穿越過程中領取的用戶特殊券是否需要作特殊標識?

a)穿越過程中領取的券,如果作了特殊標識,那麼退出時光機後,到了優惠券真實可用期後,應建議不作使用,防止佔用普通用戶資源,同時這種情況下也不建議增加優惠券已發券數量。

b)穿越過程中領取的券,不作特殊標識,那麼退出時光機後使用該券與其他正常領取的券並無差別,這種情況算是佔用了普通用戶資源,那麼相應的也建議增加優惠券已發券數量上。

a方案需要優惠券系統作相關的適配改動,但線上真實資源無任何污染或佔用;b方案無需作任何改動,但會侵佔極少量真實資源,如果運營方覺得問題不大建議採用b方案,從項目角度成本最小。

三、實現

3.1 核心流程圖

根據前述的構思方案,得出如下商城穿越核心購物流程:

3.2 改造重點

從上述流程圖中可以看出改造的重點:

  • 白名單資訊的維護

  • 獲取「當前時間」

3.2.1 白名單資訊維護

為方便後續穿越用戶時間資訊共享,我們將此資訊(openId: travelTime)存儲在配置中心中,並提供相應的管理台方便設置穿越用戶及穿越時間點。

3.2.2 獲取「當前時間」

整個上下游關聯繫統可能都會需要獲取「當前時間」,而獲取「當前時間」需要能獲取到配置的白名單資訊以及當前用戶資訊。顯然為了各個業務系統能儘可能減少程式碼變動,獲取「當前時間」適合做到一個公共模組中,各個業務系統依賴這個公共模組自動具備能獲取所期待的「當前時間」

因此集成了時光機模組後的整個業務系統鏈路關係如下所示:

3.2.3 時光機模組

從前述內容,我們可以得出時光機模組(vivo-xxx-time-travel)中需要包含的主要能力:

  • a )穿越用戶白名單資訊

  • b )獲取「當前時間」

  • c )讀取、設置上下文openId

其中a、b的實現都比較簡單,只需正常接入公司的配置中心,並根據指定openId獲取「當前時間」即可,比較麻煩一點的是獲取「當前時間」時的這個用戶openId資訊。

之前的各個業務系統間的介面調用可能是不需要用戶openId資訊的,但現在穿越用戶是指定白名單用戶的,所以必須要將入口鏈路檢測到的用戶openId資訊一路向下傳遞到下游的各個業務系統中。

方案一:各個業務系統間介面調用耦合openId資訊,需要各個業務系統全部都改造一遍,顯然這個方案比較初級原始也對各業務方非常不友好,非常不建議採用。方案二:由於我們後端各個業務系統間都使用dubbo進行介面調用,因此我們可以利用dubbo基於spi插件機制的訂製業務過濾器將openId當作附加介面調用時的附加資訊進行透傳。(如果是其他介面調用方式的,也建議採用類似原理的處理方式)

下面我們就看下時光機模組中一些核心的程式碼實現:(當前業務系統作為消費方時執行的過濾器)

當前業務系統作為消費方時執行的過濾器

/**
 * 當前業務系統作為消費方時執行的過濾器
 */
@Activate(group = Constants.CONSUMER)
public class BizConsumerFilter implements Filter {
 
    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        if (invocation instanceof RpcInvocation) {
            String openId = invocation.getAttachment("tc_xxx_travel_openId");
            if (openId == null && TimeTravelUtil.getContextOpenId() != null) {
                // 作為消費方在發起調用前,如果缺失openId,則設置上下文的openId
                ((RpcInvocation) invocation).setAttachment(openIdAttachmentKey, TimeTravelUtil.getContextOpenId());
            }
        }
        return invoker.invoke(invocation);
    }
}

當前業務系統作為服務提供方執行的過濾器;

/**
 * 當前業務系統作為服務提供方時執行的過濾器
 */
@Activate(group = Constants.PROVIDER)
public class BizProviderFilter implements Filter {
 
    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        if (invocation instanceof RpcInvocation) {
            String openId = invocation.getAttachment("tc_xxx_travel_openId");
            if (openId != null) {// 作為下游服務提供方,獲取上游系統設置的上下文openId
                TimeTravelUtil.setContextOpenId(openId);
            }
        }
        try {
            return invoker.invoke(invocation);
        } finally {
            TimeTravelUtil.removeContextOpenId();
        }
    }
}

穿越時間獲取工具類;

/**
 * 穿越時間獲取工具類
 */
public final class TimeTravelUtil {
 
    private static final ThreadLocal<TimeTravelInfo> currentUserTimeTravelInfoThreadLocal = new ThreadLocal<>();
    private static final ThreadLocal<String> contextOpenId = new ThreadLocal<>();
 
    public static void setContextOpenId(String openId) {
        contextOpenId.set(openId);
        setUserTravelInfoIfExists(openId);
    }
 
    public static String getContextOpenId() {
        return contextOpenId.get();
    }
 
    public static void removeContextOpenId() {
        contextOpenId.remove();
        removeUserTimeTravelInfo();
    }
 
    /**
     * 設置當前上下文用戶穿越資訊,如果存在的話
     * @param openId
     */
    public static void setUserTravelInfoIfExists(String openId) {
        // TimeTravellersConfig 會接入配置中心,承載所有白名單穿越用戶資訊配置,並將每一個穿越用戶資訊轉換為TimeTravelInfo
        TimeTravelInfo userTimeTravelInfo = TimeTravellersConfig.getUserTimeTravelInfo(openId);
        if (userTimeTravelInfo.isInTravel()) {
            currentUserTimeTravelInfoThreadLocal.set(userTimeTravelInfo);
        }
    }
 
    /**
     * 移除當前上下文用戶穿越資訊
     */
    public static void removeUserTimeTravelInfo() {
        currentUserTimeTravelInfoThreadLocal.remove();
    }
 
    /**
     * 當前鏈路上下文是否處於穿越中
     * @return
     */
    public static boolean isInTimeTravel() {
        return currentUserTimeTravelInfoThreadLocal.get() != null;
    }
 
    /**
     * 獲取「當前」時間,單位:毫秒。
     * 若當前是穿越中,則返回設置的穿越時間,否則返回實際系統時間
     * @return
     */
    public static long getNow() {
        TimeTravelInfo travelInfo = currentUserTimeTravelInfoThreadLocal.get();
        return travelInfo != null ? travelInfo.getTravelTime() : System.currentTimeMillis();
    }
 
}

用戶穿越資訊

/**
 * 用戶穿越資訊
 */
public class TimeTravelInfo {
    /**
     * 當前是否處於穿越中
     */
    private boolean isInTravel = false;
 
    /**
     * 當前穿越時間點,僅在isInTravel=true時有效
     */
    private Long travelTime;
 
    public boolean isInTravel() {
        return isInTravel;
    }
 
    public void setInTravel(boolean inTravel) {
        isInTravel = inTravel;
    }
 
    public Long getTravelTime() {
        return travelTime;
    }
 
    public void setTravelTime(Long travelTime) {
        this.travelTime = travelTime;
    }
}

在業務系統依賴這個vivo-xxx-time-travel模組後,凡是需要獲取當前時間的地方將原來的System.currentTimeMillis()改為TimeTravelUtil.getNow()即可。

3.4 問題

在時光機能力建設過程中碰到一個比較重要的問題,就是上下文傳遞openId資訊時,會出現跨執行緒傳遞丟失問題。

如果底層是Java執行緒池直接實現非同步調用,那通過對執行緒池相關攔截可以實現上下文複製拷貝傳遞,我們內部的全鏈路系統已經通過相關代理技術對執行緒上下文資訊已作了相關處理。如果使用Hystrix實現非同步調用,可以看下筆者另一篇專門介紹的文章《Hystrix中如何解決ThreadLocal資訊丟失》 。

四、最後

本文介紹的時光機相關能力主要應用在官網商城,但並不局限於電商場景,時光機模組在設計的時候就沒有與某個具體業務耦合,因此對於其他一些業務場景也可以適用或者有一些借鑒意義。

另外本文中電商場景中關注的是優惠價格是否正常,基本涉及到的是讀操作,如果有些場景需要穿越後進行完整的業務功能操作,如進行實際下單,那麼就會涉及到一些寫操作,此時可以藉助影子庫的相關能力去完成完整的穿越操作之旅。

作者:vivo官網商城開發團隊-Wei Fuping