项目前导
- 我们将要开发一个高性能的用户信息搜索引擎,能通过关键词、所在地、学历、性别筛选出相应的
Users - 为了更接近公司情况,报错打印日志(运用
glog)而非直接输出终端
思路总览
- 目录结构
从微到总
- 准备数据,将原始待索引文档导入数据库
- 建立索引
-
由于我们需要从关键词、所在地、学历、性别筛选出合适的对象,所以我们可以建立两个索引
- 倒排索引通过筛选条件对
Uid的搜索,正排索引通过得到的Uid返回User的详细信息
- 倒排索引通过筛选条件对
-
倒排索引:Key存储筛选条件
关键词和城市,索引链存储Uid+bits筛选- 使用sync.Map (高效的线程安全的Map)
- 筛选条件:我们可以将多个筛选条件组合成Key ,如
City+KeyWords,但不能组合过多筛选条件(Key 指数增加) - bits筛选:我们将
City+KeyWords作为Key,为了对剩下的筛选条件(性别、学历)进行筛选,我们发现(性别、学历)皆为离散属性且取值较少,可用bit 进行标识。Uid为uint32,我们将bits筛选也设为uint32,使得他们能组合成uint64性别:使用倒数两位(由于允许未知,所以有三种性别选项),00--未知,01--男,10--女城市:使用倒数第3-5位,001--大专,010--本科,011--硕士,100--博士
-
正排索引:使用高性能的LSM K/V store
badger进行存储。Key存储Uid,Value 存储User详细内容。
-
- 查询结构
- 前端通过用户的选择获取筛选项
- 通过倒排索引获取符合筛选项的
Uid,再利用正排索引获取User的信息
- 分布式索引
- 由于索引量很大,为了加快搜索引擎的速度,我们可以起多个
Server端分段创建索引 - 访问某个
Server端时,该端可通过TCP连接获取其他Server的索引结构
- 由于索引量很大,为了加快搜索引擎的速度,我们可以起多个
从总到微
- 本项目使用三台Server,读者可自选数量
- 每台Server都提供
HTTP服务,即无论访问哪个地址都能进行搜索 - 当某台Server成为
HTTP Server时,他不仅返回自己的索引结果,还作为TCP Client向其他Server索取索引结果,拼凑成完整的索引结果 - 使用搜索引擎时将筛选条件反馈给
HTTP Server,该Server端在搜索的同时也将该筛选条件通过TCP传输给其他Server端
一、准备数据
data/doc.txt是原始的待索引的文档,一行代表一个user 的信息,用逗号分成5列,分别表示uid,keywords,gender,degree,city。一次性把该文件里的内容写入数据库,后续建索引时直接从数据库读数据。
- 建表
create table if not exists user( id int not null auto_increment comment '主键自增id', uid int not null comment '用户id', keywords text not null comment '索引词', degree char(2) not null comment '学历', gender char(1) not null comment '性别', city char(2) not null comment '城市', primary key (id), unique key idx_uid (uid) )engine=innodb default charset=utf8 comment '用户信息表'; - 在
model.go里创建User结构体//默认情况下,GORM 使用 ID 作为主键,使用结构体名的 蛇形复数 作为表名,字段名的 蛇形 作为列名 type User struct{ Id int Uid uint32 Keywords string Degree string Gender string City string } //使用TableName()来修改默认的表名 func (User) TableName() string { return "user" } doc2db / main.gomain函数:连接数据库后调用MassInsert函数func main(){ flag.Parse() defer glog.Flush() dsn := "tester:123@tcp(127.0.0.1:3306)/test?charset=utf8mb4&parseTime=True&loc=Local" db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) if err != nil{ glog.Fatalf("connect to database failed: %s\n", err.Error()) } MassInsert(db, "data/doc.txt") glog.Infoln("insert into DB finish") }MassInsert函数:将doc.txt写入数据库- 由于数据量较大,我们可以批量导入节省时间
- 设置
cap=500的切片,每次读出一个User写入该切片 - 当出现错误时(读到结尾/其他错误)退出循环
- 最后检查若
users中还有数据写入数据库
func MassInsert(db *gorm.DB, corpusFile string){ fin, err := os.Open(corpusFile) //打开文件 if err != nil{ glog.Errorf("open corpusFile %s failed: %s\n", corpusFile, err.Error()) } defer fin.Close() const BATCH_SIZE = 500 //每次往数据库中批量插入BATCH_SIZE条记录 users := make([]common.User, 0, BATCH_SIZE) reader := bufio.NewReader(fin) //带buffer的方式读文本文件 for{ line, err := reader.ReadString('\n') //读取一行 if err != nil{ if err == io.EOF{ if user := parseLine(line); user != nil{ //已到文件结尾,最后没有换行符 users = append(users, *user) } }else{ glog.Errorf("read corpusFile %s's line failed %s\n", corpusFile, err.Error()) } break }else{ line := strings.TrimRight(line, "\n") //还没到文件结尾,最后有换行符,需要去掉 if user := parseLine(line); user != nil{ users = append(users, *user) } } if len(users) >= BATCH_SIZE{ if res := db.Create(users); res.Error != nil{ glog.Errorf("batch insert to database failed: %s\n", res.Error.Error()) } users = make([]common.User, 0, BATCH_SIZE) //批量插入后清空users } } if len(users) > 0{ //users中还残留一些未DB的记录 if res := db.Create(users); res.Error != nil{ glog.Errorf("batch insert to database failed: %s\n", res.Error.Error()) } } }parseLine函数:将一行内容解析为一个User实体func parseLine(line string) *common.User{ arr := strings.Split(line, ",") //以逗号分隔各列 if len(arr) >= 5{ uid, err := strconv.ParseUint(arr[0], 10, 64) if err != nil || uid < 0{ glog.Errorf("invalid line: [%s]\n", line) return nil } return &common.User{ Uid: uint32(uid), Keywords: arr[1], Gender: arr[2], Degree: arr[3], City: arr[4], } } return nil }
二、构建索引
倒排索引(index/inverted_index.go)
- 索引表
var InvertedIndex sync.Map
- bits开始设为0代表
性别未知、 Uid和bits的拼接:将Uid转换为uint64并左移32位,由于Uid原来类型为uint32,左移相当于把Uid数据放在新的uint64的高32位。再将bits转换为uint64与Uid相交,bits则在低32位。Uid和bits的拆分:Uid:uint64右移32位,Uid从原先的高32位移到低32位,高32位全为0,再转换为uint32bits:uint64左移32位,bits从原先的低32位移到高32位,低32位全为0,再右移32位,将bits移回低32位,高32位为0,再转换为uint32
// 获取user的bits
const(
BIT_MAN = 1 //男 00001
BIT_WOMAN = 1 << 1 //女 00010
BIT_JUNIOR = 1 << 2 //大专 00100
BIT_BACHELOR = 1 << 3 //本科 01000
BIT_MASTER = BIT_JUNIOR | BIT_BACHELOR //硕士 01100
BIT_DOCTOR = 1 << 4 // 博士 10000
)
func getAttributeBits(user *common.User)uint32{
bits := uint32(0)
if user.Gender == "男"{
bits = bits | BIT_MAN
}else if user.Gender == "女"{
bits = bits | BIT_WOMAN
}
switch user.Degree{
case "大专": bits = bits | BIT_JUNIOR
case "本科": bits = bits | BIT_BACHELOR
case "硕士": bits = bits | BIT_MASTER
case "博士": bits = bits | BIT_DOCTOR
}
return bits
}
// bits和Uid的拼接和拆分
func CombineDocidAndBits(uid, bits uint32)uint64{
return (uint64(uid) << 32) | uint64(bits)
}
func DisassembleDocidAndBits(combine uint64)(uint32, uint32){
uid := uint32(combine >> 32)
bits := uint32(combine << 32 >> 32)
return uid, bits
}
- 通过
strings.Buider构建倒排链的Key
// 获取倒排链的Key
const(
KEYWORDS_PREFIX = "KW"
CITY_PREFIX = "CT"
KEY_CONNECTOR = "_"
)
func extractKey(user *common.User)[]string{
city := user.City
kws := strings.Split(user.Keywords, "|")
keys := make([]string, 0, len(kws))
for _, kw := range kws{
sb := strings.Builder{}
sb.WriteString(CITY_PREFIX)
sb.WriteString(city)
sb.WriteString(KEY_CONNECTOR)
sb.WriteString(KEYWORDS_PREFIX)
sb.WriteString(kw)
keys = append(keys, sb.String())
}
return keys
}
- 插入/删除一条
User到倒排链中- 插入:
- 通过
getAttributeBits函数得到该User的bits后于Uid合并成docId - 通过extractKey得到倒排链表的
Keys - 遍历
Keys,将合并的docId存入,注意区分该Key原先是否有链- 若有链则
Store该Key和加上docId后的链(即覆盖) - 若没有则直接存储
- 若有链则
- 通过
- 删除:
- 与插入操作1、2相同获取
docId和Keys - 遍历
Keys,把每一条倒排链中的该docId删除- 建立一个新的空链,遍历倒排链中的每一个元素判断是否为
docId,不是则插入,最后覆盖该倒排链
- 建立一个新的空链,遍历倒排链中的每一个元素判断是否为
- 与插入操作1、2相同获取
- 插入:
func InsertUser2InvertedIndex(user *common.User) {
docId := CombineDocidAndBits(user.Uid, getAttributeBits(user))
keys := extractKey(user)
for _, word := range keys {
if lst, exists := InvertedIndex.Load(word); exists {
InvertedIndex.Store(word, append(lst.([]uint64), docId))
} else {
InvertedIndex.Store(word, []uint64{docId})
}
}
}
func DeleteUserFromInvertedIndex(user *common.User) {
docId := CombineDocidAndBits(user.Uid, getAttributeBits(user))
keys := extractKey(user)
for _, word := range keys { //遍历每一条倒排链,从集合中把docId剔除
if lst, exists := InvertedIndex.Load(word); exists {
docIds := lst.([]uint64)
newList := make([]uint64, 0, len(docIds))
for _, id := range docIds {
if id != docId { //把docId剔除
newList = append(newList, id)
}
}
InvertedIndex.Store(word, newList)
}
}
}
正排索引(index/forward_index.go)
- 使用高性能的LSM K/V store
badger进行存储。Key存储Uid,Value 存储User详细内容。- 纯GO 编写,针对SSD(固态硬盘)做了专门优化
- Key 存储在
.sst文件中,Value 存储在.vlog文件中
- 我们相当于是使用一个封装好的
badger,所以以下只介绍几个需要使用的接口的调用方法
// 将badger封装好
type Badger struct {
db *badger.DB
path string
}
func OpenBadger(dbPath string) (*Badger, error) // 打开指定路径文件夹作为数据存放点
func BatchInsertUser2ForwardIndex(storage *Badger, users []common.User) //把一批用户插入正排索引
创建索引(index/index.go)
INDEX_SERVER_COUNT为服务器数量,此处为3,在下一节创建- 我们通过
maxid不断读取数据库的内容,一次读取500条,读取后存储在users中 - 通过对每个
user的Uid取余判断是否该存储在本台服务器,若可以则将该user插入倒排索引 - 正排索引可以调用API直接批量插入,将判断成功的
user放入buffer,当buffer >= BATCH_SIZE时写入 - 最后检查
buffer中是否还有内容,有则写入
var (
IndexNum = flag.Int("index_num", -1, "第几台索引服务器")
)
func BuildIndex(storage *Badger, db *gorm.DB) {
if *IndexNum < 0 || *IndexNum >= INDEX_SERVER_COUNT {
glog.Fatalf("INVALID index_num %d\n", *IndexNum)
}
var maxid int //起始maxid为0
const BATCH_SIZE = 500 //每次只读取500条,循环遍历整张表
cnt := 0
buffer := make([]common.User, 0, BATCH_SIZE)
for { //遍历整张表
users := make([]common.User, 0, BATCH_SIZE)
db.Where("id>?", maxid).Limit(BATCH_SIZE).Find(&users) //读出一批user。即使不写order by id,mysql默认也是按id排序的
if len(users) > 0 {
maxid = users[len(users)-1].Id //users已按id排序,所以最后一个user的Id是最大的
for _, user := range users { //遍历从数据库中读出的每一个user
if user.Uid%INDEX_SERVER_COUNT == uint32(*IndexNum) { //分布式索引,每台索引服务器只存储一部分doc
InsertUser2InvertedIndex(&user) //把user放入倒排索引
buffer = append(buffer, user)
if len(buffer) >= BATCH_SIZE {
BatchInsertUser2ForwardIndex(storage, buffer) //把users批量放入正排索引
buffer = make([]common.User, 0, BATCH_SIZE) //清空buffer
}
cnt++
if cnt%10000 == 0 {
glog.Infof("index %d docs\n", cnt)
}
}
}
} else {
break
}
}
if len(buffer) > 0 {
BatchInsertUser2ForwardIndex(storage, buffer) //把users批量放入正排索引
}
glog.Infof("index %d docs\n", cnt)
}
三、搭建TCP服务(除特定标明外,代码都在index/distributed_index.go)
- 每个服务器都作为
HTTP Server、TCP Server、 TCP Client,可以从任意一个HttpServers访问搜索引擎
const (
INDEX_SERVER_COUNT = 3 //开启服务器数量
)
var (
IndexServers = [INDEX_SERVER_COUNT]string{"127.0.0.1:5678", "127.0.0.1:5679", "127.0.0.1:5680"}
HttpServers = [INDEX_SERVER_COUNT]string{"127.0.0.1:4678", "127.0.0.1:4679", "127.0.0.1:4680"}
)
- 每个
TCPServer都包含自己的端口号和badger store
type TcpServer struct {
port int
storage *Badger
}
func NewTcpServer(port int, storage *Badger) *TcpServer {
server := new(TcpServer)
server.port = port
server.storage = storage
return server
}
- TCPServer在每台服务器上单开一个协程跑
- 进行死循环处理所有Client发来的请求(即筛选条件),并单开一个协程处理该请求
func (server *TcpServer) Run() {
ip := "127.0.0.1"
tcpAddr, err := net.ResolveTCPAddr("tcp4", ip+":"+strconv.Itoa(server.port))
if err != nil {
glog.Fatalf("resolve tcp address failed: %s", err.Error())
}
listener, err := net.ListenTCP("tcp4", tcpAddr)
if err != nil {
glog.Fatalf("tcp listen failed: %s", err.Error())
}
for { //无限循环,处理所有socket client发来的请求
conn, err := listener.Accept()
if err != nil {
continue
}
go server.handleRequest(conn) //为每一个client单独开一个协程处理
}
}
- SocketRequest(
common/model.go):通过前端获取的筛选条件通过一个结构体封装后该服务器作为TCP CLient发送给其余服务器进行筛选,下文使用
type SocketRequest struct {
MustKeys, ShouldKeys []string
OnFlag, OffFlag uint32
OrFlags []uint32
}
- UserCollection(
common/model.go):User切片,下文使用
type UserCollection []User
handleRequest:处理TCP Client发来的请求requestBytes:Client将SocketRequest序列化后发送,requestBytes接收它request:将接收到的消息反序列化回SocketRequest- 通过
LookUp进行本服务器筛选,获取筛选结果的Uids。参数即为筛选条件(request结构体中的内容),LookUp在后续内容 - 将筛选结果的每个
Uid通过正排索引得到完整的User - 由于正排索引通过byte使用,获取的
User需要反序列化再添加进response(UserCollection) - 将
response序列化后返回给TCP Client
func (server *TcpServer) handleRequest(conn net.Conn) {
defer conn.Close()
requestBytes := make([]byte, 1024) //初始化后byte数组每个元素都是0
read_len, err := conn.Read(requestBytes)
if err != nil {
glog.Errorf("read request from socket connection failed: %s\n", err.Error())
return
}
var request common.SocketRequest
if err := sonic.Unmarshal(requestBytes[:read_len], &request); err != nil { //json反序列化时会把0都考虑在内,所以需要指定只读前read_len个字节
glog.Errorf("deserialize socket request failed: %s\n", err.Error())
return
}
var response common.UserCollection
Uids := LookUp(request.MustKeys, request.ShouldKeys, request.OnFlag, request.OffFlag, request.OrFlags)
glog.Infof("found %d result\n", len(Uids))
if len(Uids) > 0 {
response = make([]common.User, 0, len(Uids))
for _, Uid := range Uids {
if UserBytes, err := server.storage.Get(IntToBytes(Uid)); err == nil {
var user common.User
if err := sonic.Unmarshal(UserBytes, &user); err == nil {
response = append(response, user)
} else {
glog.Errorf("deserialize user failed: %s\n", err.Error())
}
} else {
glog.Errorf("could not found detail info of Uid %d from forward index\n", Uid)
}
}
}
if responseBytes, err := sonic.Marshal(response); err == nil {
if _, err = conn.Write(responseBytes); err != nil {
glog.Errorf("write reponse failed: %s\n", err.Error())
}
} else {
glog.Errorf("serialize response failed: %s\n", err.Error())
}
}
四、查找(除特定标明外,代码都在index/distributed_index.go)
两种分布式索引
-
水平切分索引
- 优势:遍历单条倒排链很快
- 劣势:倒排链跨机器求交集,逻辑复杂;正排索引冗余地存储在多台机器上
-
垂直切分索引
- 优势:某一台宕机后对搜索结果影响不大;每一台的搜索行为与单机检索时完全相同
- 劣势:倒排链长度分配不均,链查询速度由最慢的那台机器决定
-
本项目使用的是垂直切分索引
分布式查找(index/distributed_index.go)
- 前端通过提交筛选条件后,前端所在的服务器作为TCP Client向其他服务器请求筛选结果并合并返回
userChan:管道是线程安全的,用来接收TCP Server返回的筛选结果- 对每台服务器开启一个协程,作为TCP Client进行TCP连接并将
SocketRequest发送 - 读取返回结果后写入管道
- 通过
WaitGroup控制协程的结束 - 结束后将管道的内容合并并返回
func DistributedLookUp(mustKeys, shouldKeys []string, onFlag uint32, offFlag uint32, orFlags []uint32) common.UserCollection {
userChan := make(chan common.UserCollection, INDEX_SERVER_COUNT) //channel是协程安全的容器,容纳每个服务器筛选出的结果
wg := sync.WaitGroup{}
wg.Add(INDEX_SERVER_COUNT)
for _, server := range IndexServers {
go func(server string) {
defer wg.Done()
conn, err := net.DialTimeout("tcp4", server, 30*time.Minute)
if err != nil {
glog.Errorf("connect to tcp server %s failed: %s\n", server, err.Error())
return
}
defer conn.Close()
request := common.SocketRequest{
MustKeys: mustKeys,
ShouldKeys: shouldKeys,
OnFlag: onFlag,
OffFlag: offFlag,
OrFlags: orFlags,
}
if requestBytes, err := sonic.Marshal(request); err == nil {
if _, err := conn.Write(requestBytes); err == nil {
var response common.UserCollection
responseBytes := make([]byte, 1<<22)
if n, err := conn.Read(responseBytes); err == nil {
if err := sonic.Unmarshal(responseBytes[:n], &response); err == nil {
userChan <- response
glog.Infof("get %d search result from server %s\n", len(response), server)
} else {
glog.Errorf("deserialize socket response failed: %s\n", err.Error())
}
} else {
glog.Errorf("read socket response from connection failed: %s\n", err.Error())
}
}
}
}(server)
}
wg.Wait()
close(userChan)
var rect common.UserCollection = make([]common.User, 0, 1000)
for ele := range userChan {
rect = append(rect, ele...)
}
return rect
}
单机查找
- TCP Server在收到TCP Client的
SocketRequest时进行的单机查找并返回查找结果Uids haveMust和haveShould为是否存在必须全命中的关键词和只需命中一个的关键词- 如果两种关键词都没有则判定为筛选条件不完全(无KeyWords),返回nil
- 并行获取两类关键词筛选出的
Uids,由map存储,map的value无意义 - 若两种关键词都存在则对取得的结果取交集,若只有一种存在直接使用该结果
- 将map转换为切片返回
func LookUp(mustKeys, shouldKeys []string, onFlag uint32, offFlag uint32, orFlags []uint32) []uint32 {
haveMust := false
haveShould := false
if len(mustKeys) > 0 {
haveMust = true
}
if len(shouldKeys) > 0 {
haveShould = true
}
if !haveMust && !haveShould {
return nil
}
//must和should并行检索
var mustResult map[uint32]bool
var shouldResult map[uint32]bool
wg := sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
mustResult = mustFind(mustKeys, onFlag, offFlag, orFlags)
}()
go func() {
defer wg.Done()
shouldResult = shouldFind(shouldKeys, onFlag, offFlag, orFlags)
}()
wg.Wait()
var finalResult map[uint32]bool = make(map[uint32]bool, 100)
if haveMust && haveShould {
//mustResult和shouldResult取交集
for docid := range mustResult {
if _, exists := shouldResult[docid]; exists {
finalResult[docid] = true
}
}
} else if haveMust {
finalResult = mustResult
} else if haveShould {
finalResult = shouldResult
}
rect := make([]uint32, 0, len(finalResult))
for ele := range finalResult {
rect = append(rect, ele)
}
return rect
}
对象池
- 由于接下来的函数将多次使用切片,我们可以使用对象池存储使用过的切片避免一直垃圾回收
const MAX_LEN int = 200000 //遍历倒排链时,如果取出的元素超过MAX_LEN个就强行截断
var arrPool = sync.Pool{ //使用对象池,避免频繁地申请/释放大内存
New: func() interface{} {
brr := make([]uint32, MAX_LEN) //brr的cap和len都是MAX_LEN
return brr
},
}
MustFind 筛选出所有关键词都必须命中的Uids
- 如果没有必须命中的关键词 直接返回nil
results存放每个关键词得到的倒排链通过bits筛选出的Uidsresult的结束位为0,由traverseInvertList函数设置,见下文- 开启多个线程对每个
Key进行查询,将倒排链通过bits筛选出的链存入results - 返回的map存放满足
MustFind条件的Uids shortestListIndex:由于必须命中所有关键词,我们可以将每条result(即某个关键字通过bits筛选出来的Uids链)上的元素放入一个map进行计数,如果该Uid出现的次数等于关键词的数量(也就是result的条数),则证明该Uid命中所有关键词hitCountMap为计数map,记录每个Uid出现的次数- 对每条倒排链进行计数时应进行排重操作,即禁止一条链上两个相同的
Uid计数两次 - 可以用一个map记录该
Uid是否出现过
- 对每条倒排链进行计数时应进行排重操作,即禁止一条链上两个相同的
- 为了加快筛选速度我们可以记录最短的
result的索引(即shortestListIndex),将其上Uid写入map,如果某个Uid命中全部关键词,他必然存在于最短的result上- 我们只需将最短的
result中的Uid存入map中,将其余的result中的Uid进行判断是否在该map中再增加即可
- 我们只需将最短的
- 简单的例子
- 若通过最短的
result建表
- 其他
result判定插入后 - 若随机取一个建表如(北京 离职)
- 其他
result判定插入后
- 不同
result的长度可能天差地别,所以选用最短的result建表可以加快速度
- 若通过最短的
- 最后将hitCountMap中满足条件的
Uid放入返回值map
func mustFind(words []string, onFlag uint32, offFlag uint32, orFlags []uint32) map[uint32]bool {
if len(words) == 0 {
return nil
}
results := make([][]uint32, len(words))
wg := sync.WaitGroup{}
wg.Add(len(words))
shortestListIndex := -1
shortestListLen := math.MaxInt32 //取最短的那个长度
for i, word := range words {
go func(key string, i int) { //注意:在子协程中使用for range生成的变量时一定作为参数传给子协程
defer wg.Done()
lst, exists := InvertedIndex.Load(key)
if exists {
arr := lst.([]uint64)
if len(arr) > 0 {
var cl int
results[i], cl = traverseInvertList(arr, onFlag, offFlag, orFlags)//遍历倒排链,将符合结果的Uids和其数量返回
if cl < shortestListLen {
shortestListIndex = i
shortestListLen = cl //由于并发的缘故,shortestListLen不一定是最短的长度,但这个没关系,shortestListLen只是用来决定hitCountMap的Capacity
}
}
}
}(word, i) //range产生的参数一定要按值传到routine里面去,因为range每轮迭代使用的是同一个地址
}
wg.Wait()
if shortestListLen == math.MaxInt32 {
return map[uint32]bool{}
}
//先把最短的那条放到hitCountMap里
hitCountMap := make(map[uint32]int, shortestListLen)
arr := results[shortestListIndex]
for _, ele := range arr {
if ele == 0 { //遍历到arr最后一个元素了
break
}
hitCountMap[ele] = 1
}
arrPool.Put(arr) //读完后归还给traverseResultPool
//再遍历其余的,增加hitCountMap中的计数
for i, arr := range results {
if arr == nil || i == shortestListIndex {
continue
}
if len(hitCountMap) > 0 {
dup := make(map[uint32]bool, len(hitCountMap)) //对单条倒排上的元素进行排重
for _, ele := range arr {
if ele == 0 { //遍历到arr最后一个元素了
break
}
if cnt, exists := hitCountMap[ele]; exists && !dup[ele] {
hitCountMap[ele] = cnt + 1
dup[ele] = true
}
}
}
arrPool.Put(arr) //读完后归还给arrPool
}
rect := make(map[uint32]bool, len(hitCountMap))
for Uid, cnt := range hitCountMap {
if cnt >= len(words) { //如果一个doc包含了所有的words
rect[Uid] = true
}
}
return rect
}
ShouldFind 只要命中一个关键词即可
wg.Wait()前部分与MustFind相似,用total记载一共有多少个Uid被筛选出- 建表,将每一个
result中的元素存入表中并返回
func shouldFind(words []string, onFlag uint32, offFlag uint32, orFlags []uint32) map[uint32]bool {
if len(words) == 0 {
return nil
}
results := make([][]uint32, len(words))
wg := sync.WaitGroup{}
wg.Add(len(words))
var total int32 = 0
for i, word := range words {
go func(key string, i int) { //注意:在子协程中使用for range生成的变量时一定作为参数传给子协程
defer wg.Done()
lst, exists := InvertedIndex.Load(key)
if exists {
arr := lst.([]uint64)
if len(arr) > 0 {
results[i], _ = traverseInvertList(arr, onFlag, offFlag, orFlags)
atomic.AddInt32(&total, int32(len(results[i])))
}
}
}(word, i) //range产生的参数一定要按值传到routine里面去,因为range每轮迭代使用的是同一个地址
}
wg.Wait()
rect := make(map[uint32]bool, atomic.LoadInt32(&total))
for _, arr := range results {
if arr == nil {
continue
}
for _, ele := range arr {
if ele == 0 { //遍历到arr最后一个元素了
break
}
rect[ele] = true
}
arrPool.Put(arr) //读完后归还给arrPool
}
return rect
}
traverseInvertList 遍历某一条倒排链,将bits筛选后的uid切片和该切片有数据的数量返回
- 如果倒排链过长我们可分段并行处理
idx在更新时作为返回切片的索引,返回时作为返回切片的lenrect为返回切片- 分段并行将每段list中的
Uid和bits分离,进行bits筛选,若符合条件将idx+1,并在rect中存储 - 在
rect的最后一个元素置为0,标记该切片结束
func traverseInvertList(list []uint64, onFlag uint32, offFlag uint32, orFlags []uint32) ([]uint32, int) {
const INVERTED_SEG_LEN = 10000 //倒排链很长时切成很多小的片段,并行遍历
rect := arrPool.Get().([]uint32) //从缓存池里取一个int切片
segCount := (len(list) + INVERTED_SEG_LEN - 1) / INVERTED_SEG_LEN
wg := sync.WaitGroup{}
wg.Add(segCount)
var idx int32 = 0
for segIndex := 0; segIndex < segCount; segIndex++ {
//分段并行遍历
go func(segIndex int) {
defer wg.Done()
begin := segIndex * INVERTED_SEG_LEN
end := (segIndex + 1) * INVERTED_SEG_LEN
for i := begin; i < len(list) && i < end; i++ {
Uid, bits := DisassembleDocidAndBits(list[i])
if Uid > 0 && (bits&onFlag == onFlag) && (bits&offFlag == 0) { //确保有效元素都大于0,onFlag和offFlag都满足
orOk := false
if len(orFlags) == 0 {
orOk = true
} else {
for _, orFlag := range orFlags {
if orFlag > 0 && bits&orFlag == orFlag {
orOk = true
break
}
}
}
if orOk { //满足orFlags
index := atomic.AddInt32(&idx, 1) - 1
if int(index) >= cap(rect) { //强行截断
break
}
rect[index] = Uid //index是多协程共享的,slice支持多协程并发修改,多个协程不会云修改rect里的同一个元素
}
}
}
}(segIndex)
}
wg.Wait()
if int(idx) < cap(rect) {
rect[idx] = 0 //最后一个元素置为0,这一个特殊的标记
}
return rect, int(idx)
}
项目启动入口(http_server/server.go)
main函数
- 我们将在三个终端开启三个服务器
go run http_server/server.go -log_dir=search_engine/log -alsologtostderr -index_num=0/1/2 - 获取该服务器的HTTP端口和TCP端口,并设置好badger存储位置,
InitSearchEngine
func main() {
flag.Parse() //解析注册的flag
indexServer := index.IndexServers[*index.IndexNum]
httpServer := index.HttpServers[*index.IndexNum]
arr := strings.Split(indexServer, ":")
brr := strings.Split(httpServer, ":")
if len(arr) == 2 && len(brr) == 2 {
socketPort, err := strconv.Atoi(arr[1])
if err == nil {
httpPort, err := strconv.Atoi(brr[1])
if err == nil {
InitSearchEngine(socketPort, httpPort, "data/index"+strconv.Itoa(*index.IndexNum))
} else {
glog.Fatalf("INVALID http server %s\n", httpServer)
}
} else {
glog.Fatalf("INVALID socket server %s\n", indexServer)
}
} else {
glog.Fatalf("INVALID socket server %s or http server %s\n", indexServer, httpServer)
}
}
InitSearchEngine函数
- 打开数据库和badger,进行索引构建
listenSignal函数:监听服务器终止信号,即CTRL C时不报错,见下文- 启动服务器的TCP端和HTTP端
var storage *index.Badger
func InitSearchEngine(socketPort, httpPort int, indexPath string) {
// 构建索引
dsn := "tester:123@tcp(127.0.0.1:3306)/test?charset=utf8mb4&parseTime=True"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
glog.Fatalf("connect to database failed: %s\n", err.Error()) //打完日志后会调用os.Exit(255)
}
storage, err = index.OpenBadger(indexPath)
if err != nil {
glog.Fatalf("open badger failed: %s\n", err.Error())
} else {
index.BuildIndex(storage, db)
}
go listenSignal()
// 启动SocketServer
socketServer := index.NewTcpServer(socketPort, storage)
go socketServer.Run()
glog.Infoln("start tcp server")
// 启动HttpServer
HTTPRun(httpPort)
}
listenSignal函数
func listenSignal() {
c := make(chan os.Signal, 1)
//监听指定信号 SIGINT和SIGTERM。按下control+c向进程发送SIGINT信号
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
for {
select {
case sig := <-c: //接收到终止信息
glog.Infof("got signal %d, will exit\n", sig)
storage.Close()
os.Exit(0)
}
}
}
搭建HTTP服务(除特定标明外,代码都在http_server/server.go)
HTTPRun函数
- 运用Gin框架
- 加载前端页面
- 设置主页面路由
- 设置搜索路由
- 监听HTTP端口
func HTTPRun(httpPort int){
gin.SetMode(gin.ReleaseMode)
httpServer := gin.Default()
httpServer.LoadHTMLFiles("http_server/home.html")
httpServer.GET("/", func(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "home.html", gin.H{})
})
httpServer.GET("/search", handleSearchRequest)
glog.Infoln("start http server")
httpServer.Run(":" + strconv.Itoa(httpPort))
}
handleSearchRequest函数
- 从前端获取筛选项(关键词通过jieba分词器拆分)并封装成
SearchRequest,该函数见下文 - 必须填写关键词并选择城市
- 调用
SearchRequest2LookUpRequest函数(见下文)将SearchRequest转换成LookUp函数的参数 - 调用
DistributedLookUp函数获得Users返回给前端
var segger = gojieba.NewJieba() //分词器
func handleSearchRequest(ctx *gin.Context) {
query := ctx.DefaultQuery("query", "")
city := ctx.DefaultQuery("city", "")
gender := ctx.DefaultQuery("gender", "")
degree := ctx.DefaultQuery("degree", "")
keywords := segger.Cut(strings.ToLower(query), true)
request := common.SearchRequest{
MustKeys: keywords,
City: city,
Gender: gender,
Degrees: []string{degree},
}
if len(query) == 0 || len(city) == 0 {
ctx.String(http.StatusBadRequest, "请输入搜索词并选择城市")
} else {
mustKeys, shouldKeys, onFlag, offFlag, orFlags := index.SearchRequest2LookUpRequest(&request)
SearchResult := index.DistributedLookUp(mustKeys, shouldKeys, onFlag, offFlag, orFlags)
ctx.JSON(http.StatusOK, SearchResult)
glog.Infof("return %d docs\n", len(SearchResult))
}
}
SearchRequest结构体(common/model.go)
- 搜索请求,用于接收前端传输的筛选条件
type SearchRequest struct {
MustKeys []string `form:"must_keys"` //必须同时包含这些词
ShouldKeys []string `form:"should_keys"` //只需要包含这些词中的任意一个或几个
Gender string `form:"gender"` //必须是指定的性别
City string `form:"city"` //必须在指定的城市
Degrees []string `form:"degrees"` //学历需要是指定集合中的一个
}
SearchRequest2LookUpRequest(index/inverted_index.go)
- 将
SearchRequest转换成LookUp函数的参数
func SearchRequest2LookUpRequest(request *common.SearchRequest) ([]string, []string, uint32, uint32, []uint32) {
mustKeys := make([]string, 0, len(request.MustKeys))
shouldKeys := make([]string, 0, len(request.ShouldKeys))
var onFlag, offFlag uint32
var orFlags []uint32 = make([]uint32, 0, 4)
for _, ele := range request.MustKeys {
// mustKeys = append(mustKeys, CITY_PREFIX+request.City+KEY_CONNECTOR+KEYWORDS_PREFIX+ele)
sb := strings.Builder{}
sb.WriteString(CITY_PREFIX)
sb.WriteString(request.City)
sb.WriteString(KEY_CONNECTOR)
sb.WriteString(KEYWORDS_PREFIX)
sb.WriteString(ele)
mustKeys = append(mustKeys, sb.String())
}
for _, ele := range request.ShouldKeys {
// shouldKeys = append(shouldKeys, CITY_PREFIX+request.City+KEY_CONNECTOR+KEYWORDS_PREFIX+ele)
sb := strings.Builder{}
sb.WriteString(CITY_PREFIX)
sb.WriteString(request.City)
sb.WriteString(KEY_CONNECTOR)
sb.WriteString(KEYWORDS_PREFIX)
sb.WriteString(ele)
shouldKeys = append(shouldKeys, sb.String())
}
if request.Gender == "男" {
onFlag = BIT_MAN
} else if request.Gender == "女" {
onFlag = BIT_WOMAN
}
for _, degree := range request.Degrees {
switch degree {
case "大专":
orFlags = append(orFlags, BIT_JUNIOR)
case "本科":
orFlags = append(orFlags, BIT_BACHELOR)
case "硕士":
orFlags = append(orFlags, BIT_MASTER)
case "博士":
orFlags = append(orFlags, BIT_DOCTOR)
}
}
return mustKeys, shouldKeys, onFlag, offFlag, orFlags
}
前端页面交接部分实现(http_server/home.html)
- 按下搜索按钮后获得前端的值并将它们传给搜索路由
- 搜索路由调用
distributedLookUp函数后三台服务器并发筛选并拼接得到最终的Users - 将
Users传给前端拼接出一个表
<script>
$(function() {
$('#btn').click(function() {
var query=document.getElementById('query').value;
var city=document.getElementById('city').value;
var degree=document.getElementById('degree').value;
var gender=document.getElementById('gender').value;
$.ajax({
type: "GET",
url: "http://127.0.0.1:4678/search",
data:{
'query':query,
'city':city,
'degree':degree,
'gender':gender,
},
success: function(result) {
strResult = `<table border="solid" width="100%" cellpadding="5">`;
strResult += `<tr bgcolor="#67f86f"><th>uid</th><th>性别</th><th>城市</th><th>学历</th><th>标签</th></tr>`;
$.each(result,function(index,value){
strResult += `<tr><td>`;
strResult += value.Uid;
strResult += `</td><td>`;
strResult += value.Gender;
strResult += `</td><td>`;
strResult += value.City;
strResult += `</td><td>`;
strResult += value.Degree;
strResult += `</td><td>`;
strResult += value.Keywords;
strResult += `</td></tr>`;
});
strResult += `</table>`
$('#result').html(strResult);
},
}).fail(function(result,result1,result2){
alert(result.responseText);
});
});
});
</script>