最近在整理黑马点评项目的实战笔记,刚好写到第9章的“附近商铺”。这一节的核心是用 Redis 的 GEO 数据结构来做一个 LBS 场景:用户在首页选一个店铺类型,比如“美食”,系统根据用户当前坐标,把 5 公里内的店铺按距离从近到远列出来。你可能会想,这需求直接用 MySQL 查不就行了?如果数据量小,确实可以,但秒级响应和可扩展性都会打折扣。黑马点评这一节没有用 MySQL 算距离,而是把商铺坐标提前导入 Redis,再用 GEO 搜索附近的 member,这个思路非常典型,也是很多地图、外卖、点评类项目真正在用的方案。我在这篇笔记里会把 GEO 的原理、缓存预热、查询接口、分页坑和线上排障一起整理出来,适合刚学完 Redis 基础、准备做项目实战的同学参考。
1. 附近商铺功能,先搞懂它到底在解决什么问题
1.1 需求场景拆解
“附近商铺”这个功能在点评类项目里几乎是标配。用户打开 App 首页,默认定位到自己所在城市,点击“附近”页签,选择一个分类,比如美食、酒店、景点,系统返回附近若干公里内的门店,并且按距离排序。黑马点评里的入口也差不多,首页有分类 Tab,每个分类对应一批商铺,原来的查询逻辑是按 typeId 分页查数据库,但它不关心用户位置,排序只是按照某种默认权重。
第9章要加的“附近商铺”,本质上是把原来的“按类型查商铺”升级成“按类型 + 按距离查商铺”。接口入参除了 typeId 和 current 页码,还需要两个坐标参数:x 表示经度,y 表示纬度。有了这个坐标,查询结果才有一个排序依据:离我有多远。
很多人一开始会走一个弯路,把问题想复杂了。其实这个功能的输入输出非常清晰:输入是 typeId、当前位置、距离范围、页码,输出是当前页的店铺列表,列表顺序按距离由近到远。搞清楚这一点,后面设计数据结构和接口逻辑就不会跑偏。
1.2 为什么不能直接在 MySQL 里算距离
先看常规的 MySQL 方案。商铺表 shop 里有 x 和 y 两个字段,分别存经度和纬度。要查某个坐标附近 5 公里的商铺,SQL 大概长这样:
sql复制SELECT * FROM shop
WHERE type_id = ?
AND (
ACOS(
SIN(?) * SIN(y) + COS(?) * COS(y) * COS(x - ?)
) * 6371
) <= 5
ORDER BY 距离计算表达式 ASC
LIMIT ?, ?;
这个 SQL 里用到了球面余弦定理或者 Haversine 公式来算两点距离,看起来逻辑很直接。但问题也很明显:每一行都要做一次三角函数计算,type_id 上有索引也没用,因为距离条件没法走索引,只能先按 type_id 圈定一个范围,再在范围内做全量计算和排序。数据量小的时候问题不大,几十万、上百万商铺之后,这个查询会变成接口的瓶颈。
有的同学会想到用矩形范围先粗筛,再精算距离。比如给定一个中心点,算出经纬度上下限,用 BETWEEN 过滤,再用 HAVERSINE 精排。这确实能优化一部分性能,但经纬度的边界计算要考虑地球曲率,而且在跨度和精度上很难两全。更麻烦的是,这种 SQL 非常难复用,换个距离范围就要重新调整边界,维护成本很高。
GEO 方案的优势在于,距离计算和排序都交给了 Redis。Redis 内部用有序集合存储 member,score 是经纬度的 GeoHash 编码,天然就是一个支持快速范围查询的空间索引。查询附近商铺时,Redis 只需要在一个有序集合上做范围扫描,不需要对每一条数据运行三角函数,性能和实现复杂度都更有优势。
1.3 为什么黑马点评选择 Redis GEO
黑马点评这个项目本身就在大量使用 Redis,前面章节已经学了缓存、分布式锁、消息队列等场景,第9章安排 GEO,可以说是顺理成章。选择 Redis GEO,还有一个比较现实的原因:项目不需要引入额外的搜索组件,比如 Elasticsearch,也不会给 MySQL 增加复杂的空间索引依赖,Redis 本身已经足够轻量。
GEO 在 Redis 里并不是一个全新的底层数据结构,它底层是有序集合 ZSET。ZSET 的每个 member 都有一个 score,GEO 把这个 score 解释为经纬度经过 GeoHash 编码后的整数。因为这个原因,GEO 可以复用 ZSET 的所有命令,比如 ZREM 删除成员、ZCARD 获取数量,甚至可以用 ZRANGE 查看成员。只是日常使用中用专门的 GEO 命令更直观。
黑马点评在这个功能里的设计也很有代表性:每个商铺类型一个 GEO key,key 的格式是 shop:geo:{typeId}。比如 shop:geo:1 存美食类商铺,shop:geo:2 存酒店类商铺。查询附近美食时,直接在 shop:geo:1 这个 key 上做 GEO 搜索,结果天然就是美食类,不需要再做一次类型过滤。这个设计让我印象很深,不同业务域拆 key,比全部塞进一个 key 更干净,也能减少单 key 的数据量。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. GEO 的核心原理,以及动手前要准备的命令
2.1 GEO 就是“披着 ZSET 外衣的坐标库”
理解 GEO 的关键,是先理解 GeoHash 编码。地球的纬度范围是 -90 到 90,经度范围是 -180 到 180。GeoHash 的做法是把经纬度两个维度不断二分,用二进制位表示每次划分的结果,最后交叉组合成一个长的二进制串。二进制串越长,精度越高。Redis 用 52 位二进制来存这个编码,所以一个经纬度坐标会被编码成一个很大的整数,这个整数就是 ZSET 的 score。
当你要查询某个点附近的其他点时,Redis 会做一次 GeoHash 前缀匹配。前缀相同的点,在空间上一定比较接近。更进一步,Redis 内部会把经纬度转换成平面上可比较的分数,在 ZSET 里做范围查询,然后根据实际距离过滤和排序。这个过程对使用者完全透明,我们只需要调用 GEOADD 存坐标、GEOSEARCH 查坐标。
因为 GEO 底层是 ZSET,member 是唯一的。同一个 member 多次 GEOADD,坐标会被覆盖更新,这个特性在更新商铺坐标时特别有用,不需要先删除再新增。
2.2 本节要重点掌握的 Redis 命令
学习 GEO 不需要背太多命令,黑马点评这个场景最核心的是三个:
bash复制# 添加一个商铺坐标
GEOADD shop:geo:1 116.397128 39.916527 1001
# 查询某个坐标附近 5 公里内的店铺,按距离升序
GEOSEARCH shop:geo:1 FROMLONLAT 116.397128 39.916527 BYRADIUS 5 km ASC
# 删除某个商铺
ZREM shop:geo:1 1001
FROMLONLAT 表示从指定坐标出发,黑马点评里就是用户当前坐标。BYRADIUS 5 km 表示在 5 公里半径内搜索。ASC 表示结果按距离从近到远。如果你以某个商铺为中心查附近,还可以用 FROMMEMBER shop:geo:1 1001 这种写法。
另外一个命令 GEOSEARCHSTORE 可以把搜索结果存到另一个 key 中,适合需要二次处理的场景。不过黑马点评这个功能没有用到,我在后面分页部分会提一下为什么它不能直接解决分页问题。
2.3 项目里用 StringRedisTemplate 还是 RedisTemplate
黑马点评项目里大量使用了 StringRedisTemplate,附近商铺这个功能也沿用。原因有几个:第一,GEO key、member、坐标参数都是简单字符串或数字,用 StringRedisTemplate 不需要额外配置序列化器;第二,StringRedisTemplate 默认就是 RedisTemplate 的字符串版本,在 redis-cli 里看到的 key 可读性更好;第三,项目里的商铺 id 都是 Long 类型,转成 String 存储不会有什么问题。
当然,如果你在别的项目里已经配置了 GenericJackson2JsonRedisSerializer 之类的 RedisTemplate,也可以直接用,只是要注意 key 和 member 的序列化方式不要搞混。我的习惯是 GEO 相关操作统一走 StringRedisTemplate,避免序列化问题干扰定位。
Spring Boot 项目里直接注入即可:
java复制@Resource
private StringRedisTemplate stringRedisTemplate;
接下来所有缓存预热和查询代码,都是基于这个对象写的。
3. 代码落地:从缓存预热到查询接口
3.1 启动时把商铺坐标批量导入 Redis
GEO 搜索要快,前提是数据已经在 Redis 里。所以第一步是缓存预热。项目启动时,把 shop 表里所有商铺查出来,按 typeId 分组,再分别写入对应的 GEO key。
我一开始写的时候踩了个小坑:直接循环调用 opsForGeo().add 一条一条插入,本地测试没问题,但当数据量到几千条时明显变慢。后来我看了执行日志,发现每一条都是一次 Redis 网络请求。虽然预热只在启动时执行一次,但量大的时候也要注意优化。
先看最笨但最容易理解的版本:
java复制@Override
public void run(String... args) {
List<Shop> shops = shopService.list();
Map<Integer, List<Shop>> groupMap = shops.stream()
.collect(Collectors.groupingBy(Shop::getTypeId));
for (Map.Entry<Integer, List<Shop>> entry : groupMap.entrySet()) {
String key = RedisConstants.SHOP_GEO_KEY + entry.getKey();
for (Shop shop : entry.getValue()) {
Point point = new Point(shop.getX(), shop.getY());
stringRedisTemplate.opsForGeo().add(key, point, shop.getId().toString());
}
}
}
这个版本逻辑清晰,但性能一般。如果你的项目启动时对预热时间敏感,可以改成 pipeline 批量提交。下面是我在优化后用的写法:
java复制for (Map.Entry<Integer, List<Shop>> entry : groupMap.entrySet()) {
String key = RedisConstants.SHOP_GEO_KEY + entry.getKey();
List<Shop> list = entry.getValue();
stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (Shop shop : list) {
connection.geoAdd(
key.getBytes(),
new Point(shop.getX(), shop.getY()),
shop.getId().toString().getBytes()
);
}
return null;
});
}
pipeline 的好处是把多次网络请求合并成一次,对启动预热这种批量写场景非常友好。注意 executePipelined 返回的结果是 List,但这里我们不在乎返回值,直接拿到连接对象执行原始命令就行。
3.2 查询接口核心代码:GEO radius 手动实现分页
黑马点评原有的查询商铺接口是按 typeId 分页查 MySQL。增强之后,如果请求带了 x 和 y,就走 GEO 查询;如果没带坐标,比如后台管理页面不需要位置信息,就还是走原来的 MySQL 分页。
这里有一个特别关键的坑:Redis 的 GEO 命令虽然有 COUNT 参数,但没有 OFFSET 参数,也就是说服务端不能直接实现“第 2 页从第 10 条开始查”这种分页。我当时的处理方案是:先取到 end 位置的条数,也就是 current * size 条,然后在 Java 内存里再截取 from 到 to 的范围。
核心代码大致如下:
java复制public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
if (x == null || y == null) {
Page<Shop> page = query()
.eq("type_id", typeId)
.page(new Page<>(current, DEFAULT_PAGE_SIZE));
return Result.ok(page.getRecords());
}
String key = RedisConstants.SHOP_GEO_KEY + typeId;
int from = (current - 1) * DEFAULT_PAGE_SIZE;
int to = current * DEFAULT_PAGE_SIZE;
// 距离升序,先取到当前页需要的最大条数
RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs
.newGeoRadiusArgs()
.includeDistance()
.sortAscending()
.limit(to);
Circle circle = new Circle(
new Point(x, y),
new Distance(5, RedisGeoCommands.DistanceUnit.KILOMETERS)
);
GeoResults<GeoLocation<String>> results =
stringRedisTemplate.opsForGeo().radius(key, circle, args);
if (results == null || results.getContent().isEmpty()) {
return Result.ok(Collections.emptyList());
}
List<GeoResult<GeoLocation<String>>> content = results.getContent();
if (from >= content.size()) {
return Result.ok(Collections.emptyList());
}
List<GeoResult<GeoLocation<String>>> pageList = content.subList(
Math.min(from, content.size()),
Math.min(to, content.size())
);
List<Long> ids = pageList.stream()
.map(r -> Long.parseLong(r.getContent().getName()))
.collect(Collectors.toList());
if (ids.isEmpty()) {
return Result.ok(Collections.emptyList());
}
Map<Long, Shop> shopMap = listByIds(ids).stream()
.collect(Collectors.toMap(Shop::getId, shop -> shop));
List<Shop> shops = pageList.stream()
.map(r -> shopMap.get(Long.parseLong(r.getContent().getName())))
.filter(Objects::nonNull)
.collect(Collectors.toList());
return Result.ok(shops);
}
这段代码里最重要的三个点:
第一,limit(to) 不是只查一页,而是把到当前页末尾为止的所有数据全部取回来。比如第 3 页,size 是 5,Redis 就会查前 15 条,Java 再截取第 10 到第 15 条。这种方案在数据量不大时非常稳,但如果附近有几十万家店,每翻一页都会重复传输大量数据,这时候就要考虑限制最大页码或换用其他方案。
第二,listByIds(ids) 查回来的 Shop 列表顺序,和传入 id 的顺序不一定一致。MySQL 的 IN 查询结果顺序是不可靠的,所以我先把结果转成 Map,再按照 GEO 返回的 pageList 顺序重新组装。这一步很容易漏,漏掉之后前端拿到的列表顺序就会忽远忽近。
第三,subList 的边界要做双重保护。from 可能超过 content 长度,to 也可能超过 content 长度,所以要分别用 Math.min 截断,避免 IndexOutOfBoundsException。
3.3 按类型隔离:为什么 key 要设计成 shop:geo:
黑马点评没有用一个全局的 shop:geo key 存所有商铺,而是按 typeId 拆成多个 key。这个设计是故意的。
如果所有商铺都在一个 key 里,GEO 搜索的结果会包含美食、酒店、景点等所有类型。这时候你只能在返回结果里再根据每一条记录的 typeId 过滤,浪费了一次搜索能力和网络传输。拆 key 之后,查询美食类型的附近商铺,直接搜索 shop:geo:1 就行,搜索结果天然只有美食类,服务端少一次过滤,前端拿到的数据也更干净。
这个设计也有代价:同一个商铺可能同时属于多个分类吗?黑马点评里一个商铺只有一个 typeId,所以不会重复。但如果业务以后改成多分类,比如一家店既是美食又是咖啡,那就要在多个 key 里重复写入同一个 member。数据冗余会带来一致性问题,需要额外维护。当前场景下,一个 typeId 对应一个 key 是最合适的选择。
3.4 新增商铺和修改坐标时,怎么保证 GEO 数据同步
缓存预热解决的是启动时的数据导入,但项目运行中不可能一直不新增商铺。如果只在启动时写一次 GEO,之后新增的商铺就永远搜索不到。
我在做这个功能时,顺手在商铺新增和修改的 Service 里加了同步逻辑。新增商铺时,根据商铺的 typeId 拼 key,然后 GEOADD:
java复制public void addShop(Shop shop) {
save(shop);
String key = RedisConstants.SHOP_GEO_KEY + shop.getTypeId();
stringRedisTemplate.opsForGeo().add(
key,
new Point(shop.getX(), shop.getY()),
shop.getId().toString()
);
}
修改坐标时,直接再执行一次 GEOADD。因为 ZSET 的 member 唯一,同样 id 的商铺坐标会被覆盖更新。删除商铺时,用 ZREM 移除对应 member:
java复制stringRedisTemplate.opsForZSet().remove(key, shop.getId().toString());
如果你们的项目有数据变更后刷新缓存的需求,也可以先删除 key 再重新预热,但这样做代价比较大。更精细的做法是像上面这样,在写库的同时同步更新 Redis。这里有一个小提醒:一定要先保证数据库操作成功,再操作 Redis,否则万一 Redis 更新失败,数据库和缓存就产生了不一致。如果业务要求强一致,可以借助可靠消息队列或者其他分布式事务方案,但这个场景下同步更新足够用。
4. 关键设计思考:分页、距离单位和性能边界
4.1 没有 OFFSET,GEO 分页到底怎么取舍
这是一个非常值得展开的问题。Redis 6.2 之后的 GEOSEARCH 支持 COUNT 参数,但 COUNT 本质上是“限制返回数量”,并不是“从第 N 条开始取”。也就是说,Redis 端无法像 MySQL 的 LIMIT offset, size 那样做分页。
黑马点评这里用的方式,本质上是“多取 + 内存截取”。它在小数据量下没有问题,因为一个 typeId 下的商铺总数通常不会超过几千,limit 到 15 条和 limit 到 150 条差距并不大。但如果你的业务是超大范围搜索,比如全国范围内附近门店有几万条,就必须重新设计分页方案。
我思考过两种替代方案。方案一:把页码上限写死,比如最多翻 3 页,超过 3 页就提示“没有更多了”。这个方案符合大部分移动端产品的真实使用习惯,用户不会真的去翻几十页附近商户。方案二:用 GEOSEARCHSTORE 把搜索结果保存到一个临时 ZSET,再用 ZSET 的 ZRANGEBYSCORE 或者 ZRANGE 做真正的分页。但要注意,临时 ZSET 的 score 存储方式取决于命令的具体行为,用不好会绕回去,反而增加复杂度。
从实战角度,我更推荐“限制页码 + 内存截取”的组合。附近商铺是一个强地理位置相关的功能,用户翻页的意义很小,通常前几页就能完成点击转化。强行做完美服务端分页,收益不大。
4.2 距离单位与排序方向
DistanceUnit.KILOMETERS 表示搜索半径是公里。这里要注意业务上距离单位的一致性。前端定位返回的坐标和展示的距离通常都是公里,如果项目里有的地方用米,有的地方用公里,查询结果会差 1000 倍。我建议把所有 GEO 相关方法统一成一个常量或枚举,不要到处写魔法数字。
排序方向也要和产品确认。默认附近商铺都是按距离从近到远,也就是 sortAscending()。但有的产品希望把评分高、有优惠的店铺插到前面,这就要在拿到 GEO 搜索结果后,再走一次业务排序。黑马点评没有做这个复杂逻辑,所以近到远即可。
4.3 GEO 的精度和容量边界
GEO 将经纬度编码成 52 位整数,精度能到厘米级?实际使用中,定位精度主要受限于设备 GPS,Redis 自身编码的误差基本可以忽略。在附近商铺场景下,商家坐标和用户坐标都有一定误差,Redis 的精度完全够用。
容量方面,ZSET 每个 member 大约占用几十字节,几万条商铺数据轻轻松松。但要注意按 typeId 拆 key 后,如果 typeId 非常多,会产生大量小 key。Redis 对小 key 的内存管理是有效率的,但也要避免无意义的 key 堆积。黑马点评里的商铺类型数量很少,所以这不是问题。如果你的业务分类很细,比如有几千个分类,那就要评估一下拆 key 的合理性了。
4.4 空集合穿透问题
如果某个 typeId 下没有任何商铺,GEO key 不存在,每次请求都会走一次 Redis 查找,查不到再返回空。这个逻辑本身没问题,但如果有人恶意刷请求,Redis 会持续承受无意义的查询压力。
严格来说,这是缓存穿透问题。黑马点评附近商铺这节没有专门处理,但我们在项目中可以借鉴统一缓存穿透方案。最简单的方式是预热时也写入一个空值标记,或者用布隆过滤器判断该 typeId 是否可能存在于 GEO key 中。考虑到这个功能规模,我认为目前空结果穿透影响不大,但做项目时最好知道有这个问题,面试也会被问到。
5. 实战过程中遇到的问题与排查记录
5.1 启动预热时 Redis key 重复或旧数据残留
项目热重启时,会再次执行 CommandLineRunner,如果上一次已经写入了 GEO 数据,新数据会覆盖同 id 商铺的坐标,但旧数据如果已经被删除,却不会自动清理。比如你从 MySQL 删除了一家店,GEO key 里还残留它的 member,用户搜索附近商铺时就会搜到一家已删除的店。
解决办法有几种:最粗暴的是在预热前先删除对应 key,再写入。我实际采用的是先删除 typeId 对应的 key,再批量写入。这样能保证 Redis 里的数据和 MySQL 当前快照一致。
java复制Set<Object> keys = stringRedisTemplate.keys(RedisConstants.SHOP_GEO_KEY + "*");
if (keys != null && !keys.isEmpty()) {
stringRedisTemplate.delete(keys);
}
这里用 keys 通配符在小数据量下没问题,但如果生产环境 key 很多,KEYS 命令会阻塞 Redis。更稳妥的做法是维护一个已知 typeId 列表,或者用 SCAN 命令。在项目学习阶段,用 keys 快速验证是没问题的。
5.2 GEO 搜索结果和数据库坐标不一致
排查过一个问题:Redis 里能搜到店铺,但点进去详情,发现地址和坐标跟数据库对不上。原因是预热之后,有人直接在数据库后台修改了店铺坐标,但 Redis 里的 member 没有同步更新,导致 GEO 搜索用的是旧坐标。
这个问题在我上面写的同步逻辑里其实已经处理了:修改商铺时直接重新 GEOADD。但如果你只改了数据库,或者用了外部脚本同步数据,就很容易漏。排查时可以去 Redis 里查一下这个 member 的 score:
bash复制ZSCORE shop:geo:1 1001
ZSCORE 返回的是 52 位整数,看不出经纬度,可以用 GEOPOS 查看具体坐标:
bash复制GEOPOS shop:geo:1 1001
如果 GEOPOS 结果和数据库不一致,就说明 Redis 数据过期了,重新 GEOADD 即可。
5.3 分页结果出现重复或漏数据
有朋友跑完这个功能后反馈,第二页数据和第一页有重复。我看了一下代码,大概率是他的分页方案改成了“先 GEO 搜索当前页 size 条,再通过记录上次最后一个 member 继续下一页”。但 GEO 搜索是按距离排序的,如果多个商铺距离接近,排序并不稳定,缺少一个稳定的游标字段,第二页很容易出现重复或漏数据。
我自己的建议是:既然已经决定用内存截取,就老老实实每次从头取到 end 位置,不要自作聪明维护游标。如果担心性能,就限制页码。可靠性和简单性比省那点性能更重要。
5.4 Redis 连接池被打满
压测附近商铺接口时,发现 Redis 连接偶尔报超时。排查后发现不是 GEO 本身的问题,而是我在查询接口里频繁调用了 redisTemplate,又没注意释放连接。使用 StringRedisTemplate 时连接由连接池管理,一般不需要手动释放,但如果代码里混用了同步调用和 pipeline,或者循环中大量调用,连接池默认 8 个连接很容易被占满。
解决方法:确认连接池配置合理;批量操作用 pipeline;避免在事务中长时间占用连接。附近商铺单次查询只会调用一两次 Redis,正常情况下不会踩这个坑,但一旦和其他缓存操作叠加,就要特别注意。
6. 学习这节后,我对 Redis GEO 的几个新认识
6.1 GEOSEARCH 和 GEORADIUS 的版本差异
黑马点评课程里有的版本用了老命令 GEORADIUS,有的版本升级到了 GEOSEARCH。两个命令本质差不多,GEOSEARCH 更灵活,支持从坐标或 member 出发,还支持边框搜索。Spring Data Redis 3.x 版本对 GEOSEARCH 也有封装,但 API 可能和网上搜到的老教程不太一样。
如果你用的是 Spring Boot 2.7+、Redis 6.2+,建议优先使用 GEOSEARCH。如果项目里 Redis 版本低于 6.2,只能用 GEORADIUS。代码里 opsForGeo().radius() 方法底层就是 GEORADIUS,兼容性很好,我上面示例代码用的就是这个方案,就是希望在不同版本上都能跑。
6.2 从“附近商铺”这个小功能拓展出去
学完这节之后,你可以自己再延伸几个场景巩固一下:比如“附近的人”功能,本质就是每个人一个坐标,用户登录时把自己坐标写入 GEO,查询时搜附近的人;再比如“打车附近车辆”功能,车辆实时上报坐标到 Redis,乘客下单时搜索附近车辆。这些都绕不开 GEO 的存、查、同步三个环节。
我在实际项目里用 GEO 写过“附近停车场”的功能,和黑马点评的思路几乎一模一样。唯一的差别是停车场数量大、坐标更新不频繁,我在预热时用了分段批量写入,在查询时增加了距离范围和结果数量上限。把黑马点评这节吃透,再迁移到自己的业务场景,你会发现 GEO 并没有想象中那么神秘,它就是一套封装好的空间索引,关键是理解 key 怎么设计、数据怎么同步、分页怎么取舍。这几点搞明白,附近商铺这个功能就算真正掌握了。
