摘要: 想要同一時間做N個實驗?想要同一份流量不同實驗之間不干擾?想要每個實驗都能得到100%流量? 那么你就需要分層實驗。
1. 背景
-
想要同一時間做N個實驗?
-
想要同一份流量不同實驗之間不干擾?
-
想要每個實驗都能得到100%流量?

那么你就需要分層實驗。
1.1 什么是分層實驗
分層實驗概念:每個獨立實驗為一層,層與層之間流量是正交的。
簡單來講,就是一份流量穿越每層實驗時,都會再次隨機打散,且隨機效果離散。
所有分層實驗的奠基石--Goolge論文
《Overlapping Experiment Infrastructure More, Better, Faster Experimentation》
下面將以一個簡單例子來解釋分層實驗核心原理,如果要了解全貌,可以看一下上面論文
首先來看一下MD5的作為hash的特點,本文以最簡單得MD5算法來介紹分層實驗。(但一定要知道,實際應用場景復雜,需要我們設計更復雜的hash算法)
1.2 MD5 特點
-
壓縮性:任意長度的數據,算出的MD5值長度都是固定的。
-
容易計算:從原數據計算出MD5值很容易。
-
抗修改性:對原數據進行任何改動,哪怕只修改1個字節,所得到的MD5值都有很大區別。(重要理論依據!)
-
弱抗碰撞:已知原數據和其MD5值,想找到一個具有相同MD5值的數據(即偽造數據)是非常困難的。
-
強抗碰撞:想找到兩個不同的數據,使它們具有相同的MD5值,是非常困難的。
正是由於上面的特性,MD5也經常作為文件是否被篡改的校驗方式。
所以,
理論上,如果我們采用MD5計算hash值,對每個cookie 加上某固定字符串(離散因子),求余的結果,就會與不加產生很大區別。加上離散因子后,當數據樣本夠大的時候,基於概率來看,所有cookie的分桶就會被再次隨機化。
下面我們將通過實際程序來驗證。
2. 實戰講解
2.1 我們的程序介紹
-
使用java SecureRandom模擬cookie的獲取(隨機化cookie,模擬真實場景)
-
hash算法選用上文介紹的MD5。實驗分兩種:對cookie不做任何處理;對cookie采用增加離散因子離散化
-
一共三層實驗(也就是3個實驗),我們會觀察第一層2號桶流量在第2層的分配,以及第2層2號桶流量在第3層的分配
-
如果cookie加入離散因子后,一份流量經過三個實驗,按照如下圖比例每層平均打散,則證明實驗流量正交

從上圖可以看出,即使第1層的2號桶的實驗結果比其他幾個桶效果好很多,由於流量被離散化,這些效果被均勻分配到第2層。(第3層及后面層類同),這樣雖然實驗效果被帶到了下一層,但是每個桶都得到了相同的影響,對於層內的桶與桶的對比來說,是沒有影響的。而我們分析實驗數據,恰恰只會針對同一實驗內部的基准桶和實驗桶。
=>與原來實驗方式區別?
-
傳統方式,我們采用將100%流量分成不同的桶,假設有A,B兩個人做實驗,為了讓他們互不影響,只能約定0-3號桶給A做實驗,4-10號桶給B做實驗的方式,這樣做實驗,每個人拿到的只是總流量的一部分。
-
上面基於MD5分層的例子告訴我們,分層實驗可以實現實驗與實驗之間“互不影響”,這樣我們就可以把100%流量給A做實驗,同時這100%流量也給B做實驗。(這里的A,B舉例來說,一個請求,頁面做了改版(實驗A)、處理邏輯中調用了算法,而算法也做了調整(實驗B)),如果采用不采用分層方式,強行將100%流量穿過A,B,那么最終看實驗報表時,我們無法區分,是由於改版導致轉化率提高,還是算法調整的好,導致轉化率提高。
-
package com.yiche.library; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.ArrayList; import java.util.List; /** * @author shihongxing * @since 2018-09-04 17:25 */ public class MultiLayerExperiment { private static String byteArrayToHex(byte[] byteArray) { char[] hexDigits = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}; char[] resultCharArray = new char[byteArray.length * 2]; int index = 0; for (byte b : byteArray) { resultCharArray[index++] = hexDigits[b >>> 4 & 0xf]; resultCharArray[index++] = hexDigits[b & 0xf]; } return new String(resultCharArray); } private static long splitBucket(MessageDigest md5, long val, String shuffle) { String key = String.valueOf(val) + ((shuffle == null) ? "" : shuffle); byte[] ret = md5.digest(key.getBytes()); String s = byteArrayToHex(ret); long hash = Long.parseUnsignedLong(s.substring(s.length() - 16, s.length() - 1), 16); if (hash < 0) { hash = hash * (-1); } return hash; } private static void exp(SecureRandom sr, MessageDigest md5, final int LevelOneBucketNumm,/*第一層實驗桶數*/ final int LevelTwoBucketNumm,/*第二層實驗桶數*/ final int LevelThreeBucketNumm,/*第三層實驗桶數*/ final int AllFlows,/*所有流量數*/ String shuffleLevel1,/*第一層實驗離散因子*/ String shuffleLevel2,/*第二層實驗離散因子*/ String shuffleLevel3/*第三層實驗離散因子*/ ) { System.out.println("==第1層實驗 start!=="); int[] bucketlevel1 = new int[LevelOneBucketNumm]; for (int i = 0; i < LevelOneBucketNumm; i++) { bucketlevel1[i] = 0; } List<Integer> level1bucket2 = new ArrayList<Integer>(); for (int i = 0; i < AllFlows; i++) { int cookie = sr.nextInt(); long hashValue = splitBucket(md5, cookie, shuffleLevel1); int bucket = (int) (hashValue % LevelOneBucketNumm); if (bucket == 2) { /*將2號桶的流量記錄下來*/ level1bucket2.add(cookie); } bucketlevel1[bucket]++; } for (int i = 0; i < LevelOneBucketNumm; i++) { System.out.println("1層" + i + "桶:" + bucketlevel1[i]); } System.out.println("==第1層實驗 end!=="); System.out.println("==第1層2號桶流量到達第2層實驗 start!=="); int[] bucketlevel2 = new int[LevelTwoBucketNumm]; for (int i = 0; i < LevelTwoBucketNumm; ++i) { bucketlevel2[i] = 0; } List<Integer> level2bucket2 = new ArrayList<Integer>(); for (int cookie : level1bucket2) { long hashValue = splitBucket(md5, cookie, shuffleLevel2); int bucket = (int) (hashValue % LevelTwoBucketNumm); if (bucket == 2) { /*將第2層2號桶的流量記錄下來*/ level2bucket2.add(cookie); } bucketlevel2[bucket]++; } for (int i = 0; i < LevelTwoBucketNumm; i++) { System.out.println("2層" + i + "桶:" + bucketlevel2[i]); } System.out.println("==第1層2號桶流量到達第2層實驗 end!=="); System.out.println("==第2層2號桶流量到達第3層實驗 start!=="); int[] bucketlevel3 = new int[LevelThreeBucketNumm]; for (int i = 0; i < LevelThreeBucketNumm; ++i) { bucketlevel3[i] = 0; } for (int cookie : level2bucket2) { long hashValue = splitBucket(md5, cookie, shuffleLevel3); int bucket = (int) (hashValue % LevelThreeBucketNumm); bucketlevel3[bucket]++; } for (int i = 0; i < LevelThreeBucketNumm; i++) { System.out.println("3層" + i + "桶:" + bucketlevel3[i]); } System.out.println("==第2層2號桶流量到達第3層實驗 end!=="); } public static void main(String[] args) throws NoSuchAlgorithmException { SecureRandom sr = SecureRandom.getInstance("SHA1PRNG");/*用來生成隨機數*/ MessageDigest md5 = MessageDigest.getInstance("MD5");/*用來生成MD5值*/ /*1. 不對cookie做處理,一個cookie在每層實驗分到的桶是一致的*/ exp(sr, md5, 5, 5, 5, 1000000, null, null, null); System.out.println("======================="); /*2. 每層加一個離散因子,這里只是簡單的a,b,c,就可以將多層了流量打散*/ exp(sr, md5, 5, 5, 5, 1000000, "a", "b", "c"); } }
2.3 結果分析(重點)
2.3.1 不對cookie處理,每層實驗的分桶號一樣
因為hash%5中的hash保持不變,無論哪層,所以流量一直處於2號桶。
-
==第1層實驗 start!== 1層0桶:199698 1層1桶:199874 1層2桶:199989 1層3桶:200711 1層4桶:199728 ==第1層實驗 end!== ==第1層2號桶流量到達第2層實驗 start!== 2層0桶:0 2層1桶:0 2層2桶:199989 2層3桶:0 2層4桶:0 ===第1層2號桶流量到達第2層實驗 end!== ===第2層2號桶流量到達第3層實驗 start!== 3層0桶:0 3層1桶:0 3層2桶:199989 3層3桶:0 3層4桶:0 ===第2層2號桶流量到達第3層實驗 end!==
2.3.2. 對cookie做離散處理后,每層流量均勻分配
如下所示,
-
流量到達第一層時,流量被均勻分配
-
第2層實驗的2號桶流量到達第3層時,流量均勻分配到第2層的5個桶。
-
第2層實驗的2號桶流量到達第3層時,流量均勻分配到第3層的5個桶。
-
==第1層實驗 start!== 1層0桶:199951 1層1桶:199536 1層2桶:200127 1層3桶:200938 1層4桶:199448 ==第1層實驗 end!== ==第1層2號桶流量到達第2層實驗 start!== 2層0桶:40122 2層1桶:40080 2層2桶:39881 2層3桶:40096 2層4桶:39948 ===第1層2號桶流量到達第2層實驗 end!== ===第2層2號桶流量到達第3層實驗 start!== 3層0桶:8043 3層1桶:7971 3層2桶:7823 3層3桶:7956 3層4桶:8088 ===第2層2號桶流量到達第3層實驗 end!==
2.4 結論
我們觀測的第2層和第3層流量均來源於第一層的2號桶。
所以得出結論,第一層的流量在第2層、第3層均得到重新的離散分配。3. 總結
-
隨着個性化和算法不斷引入我們的應用,同一時間做多個實驗需求越來越多,更多人開始使用分層實驗。
-
實際使用中,業務場景復雜,我們會面臨需要設計更復雜的hash算法的情況,MD5是一種相對容易,效果也不錯的方式。有興趣可以關注大質數素數hash算法等更加精密優良的算法。同時,分層實驗中,為了防止流量影響,還會有“流量隔離”等更復雜的概念。
-
-
