Redis面试八股文-GEO数据类型和距离计算

背景

上古面试题:让你实现一个附近的人功能,你有什么方案?

“附近的人” 功能生活中是比较常用的,像外卖app附近的餐厅,共享单车app里附近的车辆。既然常用面试被问的概率就很大,实现方法有很多种,MySQL8之后的空间索引,MongoDB的2d索引,Redis3.2之后的GEO等都可以很高效且完美的满足需求,本文主要讨论如何用Redis GEO实现距离计算和排序,以及分页。

本文仅讨论Redis GEO 的实战应用,不讨论具体算法实现。

科普:

世界上标识一个位置,通用的做法就使用经、纬度。经度的范围在 (-180, 180],纬度的范围 在(-85, 85)左右,纬度正负以赤道为界,北正南负,经度正负以本初子午线 (英国格林尼治天文台) 为界,东正西负。

问:其他八股文都说纬度范围在-90到+90之间啊,为啥你这儿是-85到+85,你丫胡说八道!!!

答:确切的说有效纬度范围为从 -85.05112878 到 85.05112878 度,由于地球的几何形状是椭球体,不同纬度上的地球半径是不同的,这意味着在极端高纬度地区,地球表面的形状开始偏离球形,纬度值的变化不再与地球表面实际的距离变化成正比。因此,通常将纬度的范围限制在85度以内,以保证计算的精度和准确性。

测试数据准备

一般咱们到优化的地步了,数据肯定有一定量了,所以,抛开剂量谈毒性,抛开数据量谈优化都是扯淡,那么,我们先来造它个三千万条数据呗。
这里我找了以前一个社交产品的线上用户库的经纬度数据,再结合随机数,写入Redis。

截取部分数据样本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
"336" "-74.0059413" "40.7127837"
"419" "104.039485473283" "30.5069680279405"
"427" "104.075410381516" "30.5288014162613"
"564" "104.065676435045" "30.6395854135255"
"565" "103.988974449557" "30.7551896505727"
"607" "-99.5266638603988" "17.6447030436412"
"777" "-90.2714753217624" "32.3085690942718"
"800" "-94.1788629536463" "30.0809111120459"
"878" "-87.6043277" "41.7491088"
"890" "-74.580487" "39.9758756"
"900" "-83.379117700051" "42.4226956645676"
"938" "-73.4646515783357" "45.503437523005"
"952" "-73.95600334265893" "40.601518197290275"
"956" "0" "0"

写入Redis的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
func writeGeoData(geoKey string) {

    startTime := time.Now().UnixMilli()
    // 读取文件
    file, err := os.Open("july_user.txt")
    if err != nil {
        log.Fatal(err)
    }
    defer file.Close()

    var totalCount int64 = 0
    // 逐行扫描文件
    scanner := bufio.NewScanner(file)

    locationBuffer := []*redis.GeoLocation{}
    for scanner.Scan() {
        dataArray := strings.Split(scanner.Text(), "    ")
        if len(dataArray) == 3 {
            userID := strings.ReplaceAll(dataArray[0], "\"", "")
            longitude, err := strconv.ParseFloat(strings.ReplaceAll(dataArray[1], "\"", ""), 64)
            if err != nil {
                log.Fatalln(err.Error())
            }
            latitude, err := strconv.ParseFloat(strings.ReplaceAll(dataArray[2], "\"", ""), 64)
            if err != nil {
                log.Fatalln(err.Error())
            }

            if longitude > 180 || longitude < -180 {
                longitude = longitude / 2.0
            }

            if latitude > 85 || latitude < -85 {
                latitude = latitude / 2.0
            }

            locationBuffer = append(locationBuffer, &redis.GeoLocation{
                Name:      userID,
                Longitude: longitude,
                Latitude:  latitude,
            })

            if len(locationBuffer) == 500 {
                count, err := redisClient.GeoAdd(context.Background(), geoKey, locationBuffer...).Result()
                if err != nil {
                    log.Fatalln(err.Error())
                }
                totalCount += count
                locationBuffer = []*redis.GeoLocation{}
            }
        }
    }

    if len(locationBuffer) > 0 {
        count, err := redisClient.GeoAdd(context.Background(), geoKey, locationBuffer...).Result()
        if err != nil {
            log.Fatalln(err.Error())
        }
        totalCount += count
        locationBuffer = []*redis.GeoLocation{}
    }

    if err := scanner.Err(); err != nil {
        log.Fatal(err)
    }

    for i := 0; i < 25_000_000; i++ { // 2500万条随机数据
        userID := fmt.Sprintf("%d", 6522446+i)

        longitude := randFloats(-180.0, 180.0, 1)[0]
        latitude := randFloats(-85.0, 85.0, 1)[0]

        locationBuffer = append(locationBuffer, &redis.GeoLocation{
            Name:      userID,
            Longitude: longitude,
            Latitude:  latitude,
        })

        if len(locationBuffer) == 500 {
            count, err := redisClient.GeoAdd(context.Background(), geoKey, locationBuffer...).Result()
            if err != nil {
                log.Fatalln(err.Error())
            }
            totalCount += count
            locationBuffer = []*redis.GeoLocation{}
        }
    }

    fmt.Println("总数据条数:", totalCount, "组装写入耗时:", time.Now().UnixMilli()-startTime, "ms")
}

运行结果:

1
总数据条数: 31352891 组装写入耗时: 189617 ms

Redis为单核4GB内存,写入3100万条数据,占用3.25GB内存,耗时3分钟多一点点,写入数据也不算慢嘛。

实现计算附近的人

那既然咱们都有三千万用户了,那么按照二八法则,每天有个600万的活跃用户不过分吧,均分到每秒才70个不到?

那不行,整活嘛,先来他个一万的并发请求附近的人。

稳一下,别浪,咱们先看看单次请求的情况。

随机一个经纬度获取周边10公里内的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func calcDistance(geoKey string) {
    startTime := time.Now().UnixMilli()
    longitude := randFloats(-180.0, 180.0, 1)[0]
    latitude := randFloats(-85.0, 85.0, 1)[0]
    results, err := redisClient.GeoRadius(context.Background(), geoKey, longitude, latitude, &redis.GeoRadiusQuery{
        Radius:      10,
        Unit:        "km",
        WithCoord:   true,
        WithDist:    true,
        WithGeoHash: false,
        Count:       0,
        Sort:        "ASC",
        Store:       "",
        StoreDist:   "",
    }).Result()
    if err != nil {
        log.Fatalln(err.Error())
    }

    fmt.Println("条数:", len(results), "耗时:", time.Now().UnixMilli()-startTime, "ms")

    for _, result := range results {
        fmt.Println("Distance: ", result.Dist, "Longitude:", result.Longitude, "Latitude:", result.Latitude, "UserID:", result.Name)
    }
}

输出结果,随机处理5次:

1
2
3
4
5
条数: 26 耗时: 1 ms
条数: 32 耗时: 2 ms
条数: 63 耗时: 1 ms
条数: 9 耗时: 1 ms
条数: 116 耗时: 2 ms

从结果可以看出,每次由于随机位置的不同,得到10公里内数据条数不同,但出结果基本都在3ms内;
作为对比,MySQL在无空间索引的情况下,同等硬件条件,适用st_distance函数计算并排序,基本需要50秒以上才能完成计算,而且数据库CPU满载。
两相对比之下,效率至少5000倍的差距。

整活,一万并发

1
2
3
4
5
    for i := 0; i < 10000; i++ {
        go func() {
            calcDistance(geoKey)
        }()
    }

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
条数: 10 耗时: 1106 ms
条数: 15 耗时: 1106 ms
条数: 12 耗时: 1106 ms
条数: 17 耗时: 1150 ms
条数: 63 耗时: 1107 ms
条数: 15 耗时: 1106 ms
条数: 73 耗时: 1106 ms
条数: 11 耗时: 1151 ms
条数: 12 耗时: 1106 ms
条数: 15 耗时: 1106 ms
条数: 49 耗时: 1106 ms
条数: 54 耗时: 1106 ms
条数: 13 耗时: 1106 ms
条数: 23 耗时: 1150 ms
条数: 10 耗时: 1150 ms
条数: 10 耗时: 1150 ms
条数: 11 耗时: 1150 ms
条数: 24 耗时: 1106 ms
条数: 11 耗时: 1106 ms
条数: 10 耗时: 1106 ms
条数: 7 耗时: 1106 ms
条数: 23 耗时: 1106 ms
条数: 12 耗时: 1106 ms
条数: 38 耗时: 1106 ms
条数: 17 耗时: 1106 ms
条数: 9 耗时: 1106 ms
条数: 12 耗时: 1106 ms
条数: 10 耗时: 1106 ms
条数: 17 耗时: 1106 ms
条数: 27 耗时: 1106 ms
条数: 89 耗时: 1106 ms
条数: 9 耗时: 1150 ms

从结果可以看到,在一万个请求同时进行的情况下,最慢1.15秒能处理完,此时CPU峰值占用42%,也就是说完全可以支持随机的万级QPS,是不是很amazing。

注意,此处运行结果显示1150毫秒是因为Redis为单线程执行,所有的请求几乎同一时间发起,但Redis为顺序计算,最后一条实际上是所有请求都执行完成的总时间。

单次执行的时间仍然为毫秒级。

可是问题又来了,附近的人或者店的需求现实情况可能是当前城市,距离我100公里内离我最近的二百个。
那么我们调整一下参数,增大距离到100公里和数量200条,同样同时10000个请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func calcDistance(geoKey string) {
    startTime := time.Now().UnixMilli()
    longitude := randFloats(-180.0, 180.0, 1)[0]
    latitude := randFloats(-85.0, 85.0, 1)[0]
    results, err := redisClient.GeoRadius(context.Background(), geoKey, longitude, latitude, &redis.GeoRadiusQuery{
        Radius:      100,
        Unit:        "km",
        WithCoord:   true,
        WithDist:    true,
        WithGeoHash: false,
        Count:       200,
        Sort:        "ASC",
        Store:       "",
        StoreDist:   "",
    }).Result()
    if err != nil {
        log.Fatalln(err.Error())
    }

    fmt.Println("条数:", len(results), "耗时:", time.Now().UnixMilli()-startTime, "ms")

    // for _, result := range results {
    //  fmt.Println("Distance: ", result.Dist, "Longitude:", result.Longitude, "latitude:", result.Latitude, "UserID:", result.Name)
    // }
}

输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
条数: 200 耗时: 25590 ms
条数: 200 耗时: 25588 ms
条数: 200 耗时: 25589 ms
条数: 200 耗时: 25632 ms
条数: 200 耗时: 25589 ms
条数: 200 耗时: 25589 ms
条数: 200 耗时: 25632 ms
条数: 200 耗时: 25590 ms
条数: 200 耗时: 25589 ms
条数: 200 耗时: 25588 ms
条数: 200 耗时: 25632 ms
条数: 200 耗时: 25632 ms
条数: 200 耗时: 25632 ms
条数: 200 耗时: 25588 ms
条数: 200 耗时: 25633 ms
条数: 200 耗时: 25588 ms
条数: 200 耗时: 25588 ms
条数: 200 耗时: 25632 ms
条数: 200 耗时: 25632 ms
条数: 200 耗时: 25588 ms
条数: 200 耗时: 25588 ms
条数: 200 耗时: 25633 ms

从结果来看,不是很理想啊,最慢的都快到26秒了,似乎又慢到不能接受了。
此时观察Redis CPU,CPU占用率100%,满载了。

这是为什么呢?答:Redis GEO的距离计算CPU消耗会随着距离范围增大而略微增大,且Redis是单线程的,所以在CPU性能达到瓶颈之后,计算速度会略微下降(实际上每次计算仍然在1-2ms内完成)。
那怎么办呢?答:多开几个一样的实例,分别连接不同的实例进行数据同步然后计算就好了,最简单的解决方案就是开启主从模式,使用多个slave(我就不爱管国外那套政治正确的玩意儿,就爱写master-slave)进行计算。

斥巨资阿里云开1个主节点,5个从节点,直接再来线上试试效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
条数: 200 耗时: 4261 ms
条数: 200 耗时: 4261 ms
条数: 200 耗时: 4262 ms
条数: 200 耗时: 4262 ms
条数: 200 耗时: 4262 ms
条数: 200 耗时: 4262 ms
条数: 200 耗时: 4262 ms
条数: 200 耗时: 4262 ms
条数: 200 耗时: 4262 ms
条数: 200 耗时: 4262 ms
条数: 200 耗时: 4262 ms
条数: 200 耗时: 4262 ms
条数: 200 耗时: 4262 ms
条数: 200 耗时: 4262 ms

从运行结果看,效果显著,因为每个节点仅原来1/6的运算量,但需要注意:此时要使用直连模式自行分配请求到不同的节点上,代理模式整个过程依然是串行执行,不会有显著提升。

翻页

上面解决了顶住并发的问题,但在实际应用场景中很难一次返回200条数据或更多给到前端,需要翻页,而Redis的距离计算又不支持设置距离范围,这个时候怎么办呢?
答:使用Sorted Set把前面查出来的200条数据存起来,如果按照页码翻页,那直接使用zrange进行查询,如果按距离翻页,使用zrangebyscore进行查询即可,接下来上代码。

分页实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
type GeoFlipPageType int

const (
    GeoFlipPageTypeDistance GeoFlipPageType = iota
    GeoFlipPageTypePageNumber
)

func testGeoFlipPage(
    geoKey string,
    longitude,
    latitude float64,
    pageMark string,
    flipType GeoFlipPageType,
) ([]*GeoLocation, error) {
    resultsKey := fmt.Sprintf("%s_%f_%f_zs", geoKey, longitude, latitude)
    isExist, err := redisClient.Exists(context.Background(), resultsKey).Result()
    if err != nil {
        return nil, err
    }

    if isExist != 1 {
        // 如果没有数据,读取并组装
        locations, err := redisClient.GeoRadius(
            context.Background(),
            geoKey, longitude,
            latitude,
            &redis.GeoRadiusQuery{
                Radius:      100,
                Unit:        "km",
                WithCoord:   true,
                WithDist:    true,
                WithGeoHash: true,
                Count:       200,
                Sort:        "ASC",
                Store:       "",
                StoreDist:   "",
            },
        ).Result()
        if err != nil {
            return nil, err
        }

        members := []redis.Z{}
        for _, result := range locations {
            members = append(members, redis.Z{
                Score:  result.Dist,
                Member: &GeoLocation{result},
            })
        }

        // 写入sorted set缓存备用
        _, err = redisClient.ZAdd(context.Background(), resultsKey, members...).Result()
        if err != nil {
            return nil, err
        }

        // 零时数据,设置过期时间30分钟
        _, err = redisClient.Expire(context.Background(), resultsKey, time.Minute*30).Result()
        if err != nil {
            return nil, err
        }
    }

    results := []*GeoLocation{}

    switch flipType {
    case GeoFlipPageTypeDistance:
        err = redisClient.ZRangeByScore(context.Background(), resultsKey, &redis.ZRangeBy{
            Min:    pageMark,
            Max:    "100",
            Offset: 1, // 偏移一个,过滤分界线上的元素
            Count:  20,
        }).ScanSlice(&results)
        if err != nil {
            return nil, err
        }
        return results, nil
    case GeoFlipPageTypePageNumber:
        page, err := strconv.ParseInt(pageMark, 10, 64)
        if err != nil {
            log.Fatalln(err.Error())
        }

        if page < 1 {
            page = 1
        }

        page = page - 1

        err = redisClient.ZRange(
            context.Background(),
            resultsKey,
            page*20,
            (page+1)*20,
        ).ScanSlice(&results)
        if err != nil {
            return nil, err
        }
        return results, nil
    default:
        return []*GeoLocation{}, nil
    }
}

// 实现 encoding.BinaryMarshaler 接口
type GeoLocation struct {
    redis.GeoLocation
}

func (m *GeoLocation) UnmarshalBinary(data []byte) error {
    return json.Unmarshal(data, m)
}

func (m *GeoLocation) MarshalBinary() (data []byte, err error) {
    return json.Marshal(m)
}

调用代码:

1
2
3
4
5
6
7
8
9
10
11
12
func testGeo() {
    geoKey := "RedisGeoKey"

    results, err := testGeoFlipPage(geoKey, 104.067561, 30.526257, "1", GeoFlipPageTypePageNumber)
    if err != nil {
        log.Fatalln(err.Error())
    }

    for _, result := range results {
        fmt.Println("Distance: ", result.Dist, "Longitude:", result.Longitude, "Latitude:", result.Latitude, "UserID:", result.Name)
    }
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Distance:  0.0393 Longitude: 104.0679207444191 Latitude: 30.52608669604013 UserID: 6052336
Distance:  0.0468 Longitude: 104.06714826822281 Latitude: 30.52648211254099 UserID: 3247975
Distance:  0.0488 Longitude: 104.06705170869827 Latitude: 30.52624384875201 UserID: 2918923
Distance:  0.0534 Longitude: 104.067003428936 Latitude: 30.526248918194334 UserID: 3680103
Distance:  0.0566 Longitude: 104.06699270009995 Latitude: 30.526117112694045 UserID: 4066761
Distance:  0.0592 Longitude: 104.06697124242783 Latitude: 30.526416209790845 UserID: 2807467
Distance:  0.0602 Longitude: 104.06696051359177 Latitude: 30.526416209790845 UserID: 2834183
Distance:  0.061 Longitude: 104.06697124242783 Latitude: 30.52605120994391 UserID: 2981010
Distance:  0.0629 Longitude: 104.06690686941147 Latitude: 30.526302147338676 UserID: 4876728
Distance:  0.063 Longitude: 104.06690686941147 Latitude: 30.526317355665633 UserID: 3429744
Distance:  0.0705 Longitude: 104.0668585896492 Latitude: 30.52644662644476 UserID: 2833174
Distance:  0.1183 Longitude: 104.06660109758377 Latitude: 30.52692568874388 UserID: 2259396
Distance:  0.1506 Longitude: 104.06744867563248 Latitude: 30.527607528735736 UserID: 2239943
Distance:  0.168 Longitude: 104.06755059957504 Latitude: 30.527767216168776 UserID: 4833225
Distance:  0.1741 Longitude: 104.06930476427078 Latitude: 30.526697563839534 UserID: 2529638
Distance:  0.1991 Longitude: 104.06603783369064 Latitude: 30.525039856201325 UserID: 4479366
Distance:  0.2038 Longitude: 104.06939595937729 Latitude: 30.527184230302126 UserID: 5279558
Distance:  0.2103 Longitude: 104.06946033239365 Latitude: 30.5272045080714 UserID: 4413513
Distance:  0.2285 Longitude: 104.06880587339401 Latitude: 30.524505030036707 UserID: 4712364
Distance:  0.2711 Longitude: 104.06835526227951 Latitude: 30.528596069987877 UserID: 460863
Distance:  0.3088 Longitude: 104.06987875699997 Latitude: 30.524327599555555 UserID: 2183474

至此,整个Redis实现附近的人功能并翻页的功能全部完成,只需结合对应的业务数据库根据ID查询出对应的详细数据即可。

总结

1. Redis GEO用户经纬度存储和距离计算效率非常高,3100万条数据需要3GB左右的内存,妥妥的超大key。
2. 距离计算在数据量大时受限于单核CPU的瓶颈,在超多并发的情况下,可能需要等待很长时间,但可以通过增加完全相同的实例来分担压力。
3. Redis GEO计算距离原生不支持分页,需要将结果存储到List或Sorted Set来曲线结果,实现翻页功能。

程序猿老龚(龚杰洪)原创,版权所有,转载请注明出处.

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注