廣告引擎之索引介紹 —— 倒排索引
最近兩周對廣告引擎索引技術進行了一些了解,主要了解了一下索引的構成方式以及構建過程,感覺這一部分還是有一些深度,加上文檔可能貧乏,了解起來需要花費一定的時間,所以結合自己的理解,我想把這個過程紀錄下來,算是作為一份補充的參考文檔,其中可能會存在一些錯漏,希望對此部分更為熟悉的同學能予以批評指正。
圖一:信息檢索中倒排索引
但是,在我們的廣告引擎中索引卻並不是這樣,其實我們的索引更類似於傳統的數據庫索引,你可以把我們的業務場景想象為從很多的數據表中查詢符合條件的紀錄,為了加速這種查詢,我們需要給表的各個字斷建立索引。
圖二:字段倒排信息總體結構圖
索引設計目的
索引設計的目的是為了方便(加速)檢索。在一般的檢索系統一次檢索由以下兩步組成:- 根據關鍵詞(token)檢索包含token的文檔Id
- 根據文檔ID檢索到文檔內容
在我們的廣告引擎中,第1步主要用到了倒排索引(Inverted Index),第二步主要用到了正排索引(在我們的系統這部分叫做Profile)。本文主要對廣告引擎中的倒排索引進行介紹,正排索引的介紹將留到下一篇文章。
倒排索引結構
在廣告引擎中的倒排索引並不是一般信息檢索意義上的倒排索引,這也是讓我一開始的時候覺得難以理解的地方。首先介紹下傳統信息檢索的倒排索引,傳統的倒排索引主要是為了服務於文檔檢索,其目的是為了從存儲的眾多的文檔中檢索與檢索詞相關的文檔。你可以將傳統的檢索類比為一次SQL like查詢,對應的數據表documents包含兩個字段<doc_id, document>, 一次檢索相當於執行SQL語句“SELECT doc_id, document FROM documents where document LIKE ‘%<token>%’”。為了加速檢索過程,傳統的信息檢索會形成一個倒排索引,其目的是為了能快速的檢索到包含某個詞(Token)或者詞組(Term)的文檔及在文檔中的位置,你可以將這種結構想象為一個Key為字符串,Value為鏈表的HashMap(記得某位同學說過倒排太麻煩了,小公司搞個HashMap就解決了,笑)。 圖一為常見的信息檢索中的倒排索引。
廣告引擎索引結構有兩部分組成,索引元數據信息(Index Meta data)以及索引字段(Index Field)信息構成。
索引元數據信息
索引元數據信息包含索引配置信息(Index Info)以及索引資源信息(Index Resources)構成。索引配置信息
索引配置信息主要是對所有所有索引字段的描述信息,這是根據事先准備的xml配置文檔生成,有三級,分別是SIndexInfo、SIndexFiledInfo、SpayLoadFieldInfo,他們的定義見下面的代碼中的三個結構體。從代碼中可見,SIndexInfo描述了索引的總數目,提供了到每個索引字段的指針;SIndexFieldInfo描述了索引的字段的信息,包括字段名稱,來源表名,負載信息。SPayloadFieldInfo描述了字段負載信息,這些負載主要包含了一些額外的信息,比如Bidword字段的Payload信息包含出價(BidPrice),折扣(Discount),最后價格(FinalPrice)等等。/**
** @brief 倒排配置信息全集
*/
struct SIndexInfo {
SIndexFieldInfo *_pFieldInfos[MAX_INDEX_FIELD_NUM]; // 字段信息數組
int32_t _nFieldNum; // 字段個數
int32_t _nHasClassicField;
};
/**
** @brief 倒排字段配置信息
*/
struct SIndexFieldInfo {
char _szFieldName[MAX_FIELD_NAME_LEN]; // 字段名
char _szCompress[MAX_FIELD_NAME_LEN]; // 壓縮方法名
INDEX_TYPE _eIndexType; // 倒排索引類型
uint32_t _nMaxDocCount; // 倒排最大長度,0為不限制
int32_t _nIndexFieldIdx;
FIELD_SPREAD_TYPE _nSpreadType; //平鋪類型
char _szSourceTableName[MAX_TABLE_NAME_LEN]; // 來源表名
struct { //平鋪來源字段信息
int32_t _nSourceTableIdx;
int32_t _nSourcePackageIdx;
int32_t _nSourceFieldIdx;
};
SPayloadFieldInfo *_pPayloadFieldInfos[MAX_PAYLOAD_FIELD_NUM];
int32_t _nPayloadFieldNum;
};
/**
** @brief payload字段配置信息
*/
struct SPayloadFieldInfo {
char _szFieldName[MAX_FIELD_NAME_LEN];
FIELD_STORE_TYPE _eStoreType;
int32_t _nFieldBitCount;
int32_t _nFieldIdx;
int32_t _nIndexFieldIdx;
bool _bKeyField;
};
索引資源信息
索引資源信息描述了索引文件信息 ,在build索引文件的時候,我們會將索引結構(內存)以Mmap的方式同步到文件中,在索引裝載的時候,我們又會將文件以Mmap的方式映射到內存中。索引的資源信息定義如下文的代碼所示。從代碼可以看出,索引資源信息分為兩級,第一級的SIndexResource提供了到第二級的SIndexFieldResource的指針。SIndexFieldResource描述了具體的字段索引資源信息,包含一級索引文件句柄(指向一個HashMap的mmap映射),二級索引文件句柄(這個地方根據索引類型有不同的實現,有可能是B+樹,BitMap或者一般的鏈表)。/**
** @brief index資源信息
*/
struct SIndexResource {
SIndexFieldResource *_pFieldResource[MAX_INDEX_FIELD_NUM];
int32_t _count;
};
/**
** @brief index field資源信息
*/
struct SIndexFieldResource {
util::CMMapMempoolInterface *_pIdxFile; // 一級索引文件句柄
util::CMMapMempoolInterface *_pInvertListFile; // 二級索引文件句柄
util::CMMapMempoolInterface
*_pHashFilePtr; // payload string字段的去重表(hash表部分)
util::CMMapMempoolInterface
*_pDataFilePtr; // payload string字段的去重表(data部分)
SIndexFieldInfo *_pFieldInfo; //字段倒排配置信息
SGlobalInfo *_pGlobalConf; //全局配置信息
SIndexFieldResource() {
_pIdxFile = NULL;
_pInvertListFile = NULL;
_pHashFilePtr = NULL;
_pDataFilePtr = NULL;
_pFieldInfo = NULL;
_pGlobalConf = NULL;
}
~SIndexFieldResource() {}
};
索引字段信息
索引字段(IndexFiled)信息描述了索引的具體構成,包含一級索引(token的HashMap),二級索引(doc id的集合、具體表現形式因索引類型不同而不同)。這部分主要包含四個結構,分別是SIdx1Unit(一級索引)、SIdx2Unit(二級索引)、IndexField(索引字段)以及CIndexTerm,具體定義見下面的代碼。// 一級倒排結構
struct SIdx1Unit {
uint64_t sign; // token 簽名
union {
struct {
uint64_t num:26; // doc數量, 最多1.3億
int64_t beginOffset:38; // 二級倒排起始偏移,最多64G
};
uint64_t numOffset;
};
};
// 二級倒排基本結構
typedef struct SIdx2Unit {
uint32_t docId; // doc-id
} SIdx2Unit;
// 二級倒排結構,帶occ
typedef struct SIdx2UnitOcc: public SIdx2Unit {
uint16_t occ; // token在docid中的位置occ
} SIdx2UnitOcc;
struct SIndexFieldMergeInfo {
uint64_t nDocCount;
CIndexField *pIndexField;
};
// 字段倒排管理器,內部,基類
class CIndexField {
protected:
// deletemap 管理器
CDeleteMap *_pDelMap;
// 一級索引
util::CHashTable<SIdx1Unit> *_pIdx;
util::CMMapMempoolInterface *_pIdxFile;
// 二級索引
util::CMMapMempoolInterface *_pInvertListFile;
};
class CIndexTerm {
public:
// 獲取倒排長度
virtual uint32_t getDocNum() = 0;
// 獲取倒排鏈,做deletemap過濾
virtual int32_t getDocList(uint32_t *pDocList, char **pPayloadList) = 0;
// 獲取倒排鏈地址
virtual const void *getDocList() = 0;
// 根據當前傳入的docid取出第一個等於或大於的docid,用於外層歸並
virtual uint32_t seek(uint32_t nDocId, char *&pPayload) { return INVALID_DOCID; }
// 設置deletemap管理器
void setDeleteMap(CDeleteMap *pDelMap);
protected:
CDeleteMap *_pDelMap;
uint32_t _nDocNum;
const char *_pInvertList;
int32_t _nDocUnitLength;
uint32_t _pos;
};
- 上文說一級索引實際上一個關於Token/Term的HashMap(實際用使用的是HashTable),SIdx1Unit就是這個HashMap的Key,它包含Token/Term的hash值,以及倒排鏈中的文檔個數(num)以及二級索引起始地址(beginOffset),注意這里beginOffset指向的是內存偏移位置量,我們自己實現內存的分配器MemPool。
- SIdx2Unit和SIdx2UnitOcc用於描述倒排鏈中的單元,包含文檔Id和Token/Term位置信息。
- CIndexFiled描述了字段索引的信息,包含刪除映射表(CDeleteMap),用於維護被刪除的文檔id,一級索引映射表(_pIdx,一個key為token的hash表)以及_pIdxFile和_pInvertListFile兩個mmap文件句柄。
- CIndexTerm描述相應的Token的倒排鏈,在CIndexFiled包含一個getTermReader用於獲取指定Token的CIndexTerm倒排鏈。該結構包含刪除映射表(_pDelMap)、token/term對應文檔數量、倒排表(可能問線性表或者樹),單位文檔字節長度,當前指針位置。
總體結構圖
圖二為字段信息所有的結構的總體結構圖。
一次檢索過程
從 圖二可以看出,只要找到要檢索字段所對應的CIndexField結構,檢索就會變得非常簡單。前文提到CIndexInfo維護了到所有的CIndexField的指針,因此只需要遍歷一遍就可以輕松獲取目標CIndexFiled結構。比如我們檢索title字段為iphone的所有文檔,我們先要獲取title字段的CIndexFiled結構,然后根據iphone的hash值獲取一級索引(_pIdx)對應的結構SIdxUnit1 su1,再根據su1的offset值獲取到倒排鏈,倒排鏈中包含了所有符合條件的文檔及相關信息。索引類型
前文提到,我們廣告引擎系統的索引和傳統數據庫系統非常類似,因此對應傳統數據庫索引類型,我們廣告引擎中也存在三種類型的索引,分別是:一般索引、Bitmap索引、B+樹索引,他們適用的場景也和傳統數據庫系統一般無二。一般索引
在這種索引中,二級倒排鏈按關鍵字(一般為docId)順序組成順序表(可能是一塊連續的存儲也有可能是鏈式結構,不過一般情況下應該為順序表)。這種索引適用於倒排表的較小,比如可以放進一頁內存的情況下,這種情況下使用這種連續存儲的順序表的一般索引應該是非常理想的,因為這個時候我們可以對單個關鍵字方便的進行二分查找,並且查詢一段范圍(range)的關鍵字只要做兩次二分查找就可以了,因此查找性能非常優秀。同時,在采用連續存儲的情況下,這種索引避免了其他指針開銷,也很節省內存空間。但是,當倒排鏈非常大的情況下,這種索引就不使用,這個時候查找需要在很大范圍內進行,內存頁cache命中變低以及尋址帶來的開銷會導致查找性能變低。Bitmap索引
在字段取值范圍比較固定的時候,比較適用采用Bitmap(位圖)索引。比如Sex字段只有Man和Women兩種,我們可以給每個關鍵詞建立一個位圖數組,用指定位置的一個bit位表示對應docId是否在相應的關鍵字倒排鏈中。采用Bitmap索引可以非常有效的節省存儲空間,同時由於檢索只要進行bit運算,因此效率也會非常高,但是bitmap不適合取值范圍較大且較為均勻的字段索引(因為bitmap會為每個關鍵字建立一個完整的bitmap數組,取值范圍大會使得空間消耗巨大),同時bitmap也不適合更新平凡的字段索引(因為采用bitmap索引,同一時間對於一個bitmap數組的寫入是互斥的)。B+樹索引
當字段取值較為分散,且二級倒排鏈規模較大的時候我們一般采用B+樹索引。B+樹有幾個特點,首先它是平衡的(通過結點的裝載度保證),其次B+樹的內部結點(葉子結點之外的結點)不存儲數據,使得內部結點可以最大限度的存儲更多的key,從而是樹有更多的分支,從而使得樹更加的扁平(高度一般不超過10),最后B+樹的葉子結點形成一個構成一個鏈表,使得便利B+樹變得很容易(范圍檢索更容易),對B+樹感興趣的可以參見 B+Tree。在廣告引擎索引設計中,我們保證每個B+樹內部結點為占用空間為一頁以內,這樣每一次我們都可以在一頁內進行二分關鍵字檢索,同時樹高度很低使得我們需要遍歷的結點數目少。B+樹使得內存查找局部化請參考 文章。索引構建過程
在我們的廣告引擎中,索引(全量)構建分為三個階段,分別是數據准備階段、小索引建立階段、索引合並階段。數據准備
在此階段,主要是將數據庫中的表數據dump出來,同時完成相關的數據的拼接組裝,比如廣告數據可能存在多張表中,需要將這些表按關鍵字拼接起來形成一張大表。這個階段會產生幾個描述幾個xml文件,分別代表幾種業務類型的數據,數據格式見下面。<doc>
adgroupId=100083699
catId=11,110207
defPrice=30
expire=2114380800
goodsPrice=120.00
goodsId=8248681432
campaignId=3481560
custId=1103154803
price=0
modTime=1334573076
catStatus=1
nonsearchMaxPrice=0
propertyId=21511,21943,120173,21940,32999,65235,65262,65266,21517,65256,65268,21385,21456
adgExtension=ordinaryPostFee:1200;isCommend:1;transitFee:10.00;vipDiscountRate:goldCard~100$platinaCard~100$diamondCard~100;location:北京;isNew:1;isPostFree:0;isSupportVip:0;spuId:44086;skuPrice:
adgTitle=全新庫存希捷硬盤120G台式機IDE接口1年包換 元送3件禮品
adgTags=0
sell=0
sell1=0
sell7=0
score=0
adgroupStatus=1
doPrice=30
rankScore=0
postage=10.00
location=北京
locationId=19
catIdIdx=11 110207
catPropIds=110207
keywords=價格便宜11907686613,0,4,319163397一年質保11907686617,0,4,252054788硬盤11907686623,0,4,1645298193120G11907686625,0,4,1594966545全新 庫存11907686620,0,4,1611743761台式機並口硬盤11907686615,0,4,1074869777希捷11907686628,0,4,1662074897
templateStatus=0
</doc>
<doc>
adgroupId=110083699
catId=11,110207
defPrice=30
expire=2114380800
goodsPrice=120.00
goodsId=8248681432
campaignId=3481560
custId=1103154803
price=0
modTime=1334573076
catStatus=1
nonsearchMaxPrice=0
propertyId=21511,21943,120173,21940,32999,65235,65262,65266,21517,65256,65268,21385,21456
adgExtension=ordinaryPostFee:1200;isCommend:1;transitFee:10.00;vipDiscountRate:goldCard~100$platinaCard~100$diamondCard~100;location:北京;isNew:1;isPostFree:0;isSupportVip:0;spuId:44086;skuPrice:
adgTitle=全新庫存希捷硬盤120G台式機IDE接口1年包換 元送3件禮品
adgTags=0
sell=0
sell1=0
sell7=0
score=0
adgroupStatus=1
doPrice=30
rankScore=0
postage=10.00
location=北京
locationId=19
catIdIdx=11 110207
catPropIds=110207
keywords=價格便宜11907686613,0,4,319163397一年質保11907686617,0,4,252054788硬盤11907686623,0,4,1645298193120G11907686625,0,4,1594966545全新 庫存11907686620,0,4,1611743761台式機並口硬盤11907686615,0,4,1074869777希捷11907686628,0,4,1662074897
templateStatus=0
</doc>
小索引建立
第一階段產生的數據文件,會被切分成許多小文件(以doc為單元,保證每個doc的完整性)。這些小文件會被很多hadoop實例加載並build成許多的小索引,小索引是構建在內存中,並通過mmap文件映射的方式寫入到磁盤文件中。在build開始程序會讀入兩份配置文件,配置文件1指定了建立索引的表名,對應文件存儲位置,文件數量等信息;配置文件2指定了建立索引的字段名稱,字段存儲類型等信息。build文件會依次處理第一階段產生數據文件中的doc,將其轉換成kv結構,並插入到倒排結構中,具體過程可以閱讀下面一段核心代碼。//倒排
if (_pIndexInfo) {
SIndexFieldInfo *pIndexFieldInfo = NULL;
SPayloadFieldInfo *pPayloadFieldInfo = NULL;
for (nIndexFieldIdx = 0; nIndexFieldIdx < _pIndexInfo->_nFieldNum;
++nIndexFieldIdx) {
pIndexFieldInfo = _pIndexInfo->_pFieldInfos[nIndexFieldIdx];
for (nPayloadFieldIdx = 0;
nPayloadFieldIdx < pIndexFieldInfo->_nPayloadFieldNum;
++nPayloadFieldIdx) {
pPayloadFieldInfo =
pIndexFieldInfo->_pPayloadFieldInfos[nPayloadFieldIdx];
it = record.find(string(pPayloadFieldInfo->_szFieldName));
if (it == record.end()) {
LOG_ERROR("Field %s is required.", pPayloadFieldInfo->_szFieldName);
return -1;
}
_pFields[nFieldPtr].szName = pPayloadFieldInfo->_szFieldName;
_pFields[nFieldPtr].szValues = getIndexFieldValue(it->second.c_str(),
&_pFields[nFieldPtr].nValueCount,
pPool);
if (!_pFields[nFieldPtr].szValues) {
LOG_ERROR("getIndexFieldValue() failed.");
return -1;
}
++nFieldPtr;
}
it = record.find(string(pIndexFieldInfo->_szFieldName));
if (it == record.end()) {
LOG_ERROR("Field %s is required.", pIndexFieldInfo->_szFieldName);
return -1;
}
_pFields[nFieldPtr].szName = pIndexFieldInfo->_szFieldName;
_pFields[nFieldPtr].szValues = getIndexFieldValue(it->second.c_str(),
&_pFields[nFieldPtr].nValueCount,
pPool);
if (!_pFields[nFieldPtr].szValues) {
LOG_ERROR("getIndexFieldValue() failed.");
return -1;
}
++nFieldPtr;
}
}
//判斷總數是否相符
if (nFieldPtr != _nTableFieldNum + _nIndexFieldNum) {
LOG_ERROR("%d fields parsed, but %d fields required.",
nFieldPtr,
_nTableFieldNum + _nIndexFieldNum);
return -1;
}
//去重
qsort(_pFields, nFieldPtr, sizeof(update::SField), fieldCmp);
for (int i = 1; i < nFieldPtr;) {
if (strcmp(_pFields[i].szName, _pFields[i - 1].szName) == 0) { //相同字段
for (int j = i; j < nFieldPtr - 1; ++j) {
memcpy(&_pFields[j], &_pFields[j + 1], sizeof(update::SField));
}
nFieldPtr--;
} else {
++i;
}
}
//call indexupdate
ret = _indexUpdate.add(_pTableInfo->_szTableName, _pFields, nFieldPtr, pPool);
if (ret != 0) {
LOG_ERROR("IndexUpdate::add() failed.");
return -1;
}