概述
「搜附近」是交友项目里唯一一个强依赖地理位置的功能:它不关心你和谁互有好感,只回答一个问题,此刻谁在你身边。这里是这一功能的全景分析:产品形态长什么样、整体技术方案怎么搭、每个环节有哪些必须盯住的点。单步的原理推演和逐行代码在同章其他篇里展开。
纲要
- 功能场景
- 产品形态:按距离圈出附近的人
- 接口定义与入参出参
- 整体技术方案
- 上报 → 存储 → 检索的三段式链路
- 为什么用 MongoDB 而不是 MySQL
- 各环节要点速查表
- 查询点清单(踩坑高发区)
功能场景:按距离圈出附近的人
项目首页顶部的「搜附近」入口点进去,是一个按距离组织的用户卡片列表。产品和别的推荐位不一样的地方在于排序依据:项目卡片按缘分值随机排,这里的每张卡片都对应一个真实地理坐标落在你设定半径内的人。
交互上有两个可控条件:
- 距离:默认 2000 米,用户可调
- 性别:可选筛选,只看男生或只看女生
对应的接口定义(YApi 地址 project/19/interface/api/121):
| 项 | 内容 |
|---|---|
| 请求路径 | GET /mf/search |
| 请求参数 | gender(可选,性别筛选)、distance(默认 "2000",单位米) |
| 响应数据 | List<NearUserVo>,每项含 userId、avatar、nickname |
响应 VO 的结构很瘦,只带展示必需的三个字段:
java
//附近的人vo对象
public class NearUserVo {
private Long userId;
private String avatar;
private String nickname;
public static NearUserVo init(UserInfo userInfo) {
NearUserVo vo = new NearUserVo();
vo.setUserId(userInfo.getId());
vo.setAvatar(userInfo.getAvatar());
vo.setNickname(userInfo.getNickname());
return vo;
}
}
搜索结果页大致长这样:
doc
┌──────────────────────────────┐
│ ← 搜附近 [筛选] │
├──────────────────────────────┤
│ 距离: 2km ▼ 性别: 全部 ▼ │
├──────────────────────────────┤
│ ┌────────┐ ┌────────┐ │
│ │ 头像 │ │ 头像 │ │
│ │ 玫瑰 │ │ 婉儿 │ │
│ │ 800m │ │ 1.2km │ │
│ └────────┘ └────────┘ │
│ ┌────────┐ ┌────────┐ │
│ │ 头像 │ │ 头像 │ │
│ │ 阿哲 │ │ 小鹿 │ │
│ │ 1.5km │ │ 1.8km │ │
│ └────────┘ └────────┘ │
└──────────────────────────────┘
要点:
- 卡片按距离从近到远呈现(产品预期),距离值来自地理位置计算
- 无位置数据的人不出现在结果里,没上报过位置,就检索不到
- 筛选条件(性别)不走地理位置库,走
UserInfoApi的二次过滤
整体技术方案:上报、索引、检索三段式
搜附近不是一个接口能独立完成的功能,它建立在一套前置数据之上。整条链路分三段:
#mermaid-svg-dtNuwdb46IycojkS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dtNuwdb46IycojkS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dtNuwdb46IycojkS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dtNuwdb46IycojkS .error-icon{fill:#552222;}#mermaid-svg-dtNuwdb46IycojkS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dtNuwdb46IycojkS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dtNuwdb46IycojkS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dtNuwdb46IycojkS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dtNuwdb46IycojkS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dtNuwdb46IycojkS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dtNuwdb46IycojkS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dtNuwdb46IycojkS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dtNuwdb46IycojkS .marker.cross{stroke:#333333;}#mermaid-svg-dtNuwdb46IycojkS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dtNuwdb46IycojkS p{margin:0;}#mermaid-svg-dtNuwdb46IycojkS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-dtNuwdb46IycojkS .cluster-label text{fill:#333;}#mermaid-svg-dtNuwdb46IycojkS .cluster-label span{color:#333;}#mermaid-svg-dtNuwdb46IycojkS .cluster-label span p{background-color:transparent;}#mermaid-svg-dtNuwdb46IycojkS .label text,#mermaid-svg-dtNuwdb46IycojkS span{fill:#333;color:#333;}#mermaid-svg-dtNuwdb46IycojkS .node rect,#mermaid-svg-dtNuwdb46IycojkS .node circle,#mermaid-svg-dtNuwdb46IycojkS .node ellipse,#mermaid-svg-dtNuwdb46IycojkS .node polygon,#mermaid-svg-dtNuwdb46IycojkS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dtNuwdb46IycojkS .rough-node .label text,#mermaid-svg-dtNuwdb46IycojkS .node .label text,#mermaid-svg-dtNuwdb46IycojkS .image-shape .label,#mermaid-svg-dtNuwdb46IycojkS .icon-shape .label{text-anchor:middle;}#mermaid-svg-dtNuwdb46IycojkS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-dtNuwdb46IycojkS .rough-node .label,#mermaid-svg-dtNuwdb46IycojkS .node .label,#mermaid-svg-dtNuwdb46IycojkS .image-shape .label,#mermaid-svg-dtNuwdb46IycojkS .icon-shape .label{text-align:center;}#mermaid-svg-dtNuwdb46IycojkS .node.clickable{cursor:pointer;}#mermaid-svg-dtNuwdb46IycojkS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-dtNuwdb46IycojkS .arrowheadPath{fill:#333333;}#mermaid-svg-dtNuwdb46IycojkS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-dtNuwdb46IycojkS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-dtNuwdb46IycojkS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dtNuwdb46IycojkS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dtNuwdb46IycojkS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dtNuwdb46IycojkS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-dtNuwdb46IycojkS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-dtNuwdb46IycojkS .cluster text{fill:#333;}#mermaid-svg-dtNuwdb46IycojkS .cluster span{color:#333;}#mermaid-svg-dtNuwdb46IycojkS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-dtNuwdb46IycojkS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dtNuwdb46IycojkS rect.text{fill:none;stroke-width:0;}#mermaid-svg-dtNuwdb46IycojkS .icon-shape,#mermaid-svg-dtNuwdb46IycojkS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dtNuwdb46IycojkS .icon-shape p,#mermaid-svg-dtNuwdb46IycojkS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-dtNuwdb46IycojkS .icon-shape .label rect,#mermaid-svg-dtNuwdb46IycojkS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dtNuwdb46IycojkS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-dtNuwdb46IycojkS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-dtNuwdb46IycojkS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 变化>500m 或间隔5min
GET /mf/search
客户端获取定位
POST /baidu/location
UserLocationApiImpl
save 或 updateFirst
user_location 集合
GeoJsonPoint + 2dsphere 索引
queryNearUser
withinSphere 圆球查询
userId 列表
UserInfoApi 补全昵称头像
NearUserVo 列表
三段各自的职责:
- 上报段 :客户端在位置变化超过 500 米或每隔 5 分钟时调
POST /baidu/location,服务端把经纬度包成GeoJsonPoint写入 MongoDB。这是数据来源,没有这一段,检索是空转。 - 存储段 :
user_location集合一个用户一条,location字段存GeoJsonPoint,靠@CompoundIndex(def = "{'location': '2dsphere'})建地理位置索引。索引是地理查询的硬前提,没建 2dsphere 索引,$geoWithin这类查询要么报错要么全表扫。 - 检索段 :以「我的坐标」为圆心、
distance为半径画圆,用withinSphere圈出范围内所有位置文档,提取userId,再回 MySQL 补全用户资料。
一个自然的问题:MySQL 加经纬度两个字段也能算距离,为什么要引 MongoDB?算一笔账就清楚了,MySQL 里查「附近的人」只能用边界矩形粗筛(WHERE lat BETWEEN ... AND lng BETWEEN ...),把所有候选行拉到内存里逐个算勾股距离再排序,数据量一大就是灾难;而且MySQL 对这种查询用不上高效的空间索引(5.7 之前的版本基本无解)。MongoDB 的 2dsphere 索引在写入时就把坐标组织成了球面可检索的结构,withinSphere 一次圈定,且天然支持「按距离排序」的升级路径($geoNear)。地理位置检索是 MongoDB 相对关系型数据库的传统强项,这里用它名正言顺。
各环节要点速查表
| 环节 | 所在模块 | 关键类/字段 | 关键 API 或注解 | 产出/约束 |
|---|---|---|---|---|
| 位置上报 | mf-app-server | BaiduController / BaiduService |
POST /baidu/location |
经纬度 + 地址描述 |
| 位置写入 | mf-dubbo-mongo | UserLocationApiImpl.updateLocation |
mongoTemplate.save / updateFirst |
一用户一条,覆盖更新 |
| 存储结构 | mf-model | UserLocation.location |
GeoJsonPoint + @CompoundIndex(2dsphere) |
x 存经度,y 存纬度 |
| 范围检索 | mf-dubbo-mongo | UserLocationApiImpl.queryNearUser |
Distance → Circle → withinSphere |
半径单位:米转千米 |
| 资料补全 | mf-app-server | mfService.queryNearUser |
userInfoApi.findByIds |
性别筛选在此层做 |
| VO 组装 | mf-model | NearUserVo.init |
静态工厂 | 只透出三个字段 |
查询点清单
实现这条链路时,下面这些点是实测容易出错或留有缺口的地方,先列在这里,细节在同章各篇展开:
- 坐标顺序 :
GeoJsonPoint构造参数是(经度, 纬度),即 x 在前。按(纬度, 经度)传不报错,但查出来的「附近」全在世界另一头,GeoJSON 标准就是先经后纬,写反了很难从日志里看出来。 - 单位换算 :
queryNearUser收到的是米,Distance构造时必须metre / 1000转成千米再配Metrics.KILOMETERS。漏掉换算,搜「2000 米」实际搜的是 2000 千米。 - 无排序 :
withinSphere只做范围过滤,不保证结果按距离从近到远。要按距离排序,得换$geoNear(NearQuery+geoNear)。 - 无分页 :当前实现一次圈定就
find全量返回,附近人密度高的市中心,一次可能返回成百上千条。 - 结果含自己 :圆心是自己的坐标,自己必然在圆内。排除逻辑放在了 app-server 层的 VO 组装循环里,dubbo 层返回的
userId列表是包含调用者本人的。 - Movement 的经纬度是 String :动态表(
movement)的longitude/latitude字段是String类型,将来做「附近的动态」时需要Double.parseDouble转换并改造成GeoJsonPoint,这是留给后续章节的一个已知改造点。 - GeoJsonPoint 不能过 Dubbo :
GeoJsonPoint没有实现 Dubbo 可用的序列化,所以UserLocationApi的方法签名里只传Double longitude / Double latitude原始值,坐标对象只在 dubbo-provider 内部构造。跨服务只传数字,别传坐标对象。
常见误解澄清
| 常见误解 | 实际情况 |
|---|---|
| 「搜附近」结果里的人是系统推荐匹配的 | 结果完全由地理坐标圈定,与缘分值、推荐模型无关;没上报位置的人永远不出现 |
| MongoDB 的地理查询不需要建索引也能跑 | withinSphere/$geoNear 必须有 2dsphere(或 2d)索引,否则报错或退化为低效扫描 |
GeoJsonPoint(longitude, latitude) 参数顺序无所谓 |
必须先经度后纬度;写反不报错,但圆心落在错误的半球,结果整批作废 |
distance 参数传米,Distance 直接用 |
Distance 构造的是千数值,必须先 metre / 1000,否则半径放大一千倍 |
| 搜附近只能查人,动态是另一套方案 | 同一套 2dsphere + 范围查询可以直接复用到动态表,只是 Movement 的 String 经纬度需要先改造 |
总结
搜附近的功能全景可以压成一句话:客户端定期上报,MongoDB 用 2dsphere 索引管住每人的最新坐标,查询时以自己为圆心画圆圈人。方案本身不复杂,复杂性都藏在细节里,单位换算、坐标顺序、排序分页的缺口、String 经纬度的历史包袱。数据从哪来、怎么存进去,是这条链路的第一步。
下一篇上报地理位置:思路分析先解决「位置怎么进来」:上报时机、存储结构设计、一用户一条的覆盖更新策略。
为什么单独拆一篇做功能概述
搜附近这条链路横跨四个模块:客户端负责采集定位,mf-app-server 负责接收上报和对外提供接口,mf-dubbo-mongo 负责与 MongoDB 打交道,mf-model 负责承载存储结构与 VO。任何一处理解偏差,最终都会表现为「搜出来的人不对」这种极难排查的现象------它不报错,只是结果错了。
所以本篇不写代码,只做三件事:
- 把产品形态和接口契约固定下来,明确「输入是什么、输出是什么」
- 把三段式链路画清楚,让每个环节的边界和职责一目了然
- 把已知的坑提前列出来,后续各篇展开时对照着看
数据流视角:一次搜索请求经历了什么
从用户点开「搜附近」到卡片渲染出来,一次请求大致经过下面几步:
"mf-dubbo-user" "MongoDB" "mf-dubbo-mongo" "mf-app-server" "客户端" "mf-dubbo-user" "MongoDB" "mf-dubbo-mongo" "mf-app-server" "客户端" #mermaid-svg-doBqpeH35bziDPF8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-doBqpeH35bziDPF8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-doBqpeH35bziDPF8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-doBqpeH35bziDPF8 .error-icon{fill:#552222;}#mermaid-svg-doBqpeH35bziDPF8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-doBqpeH35bziDPF8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-doBqpeH35bziDPF8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-doBqpeH35bziDPF8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-doBqpeH35bziDPF8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-doBqpeH35bziDPF8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-doBqpeH35bziDPF8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-doBqpeH35bziDPF8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-doBqpeH35bziDPF8 .marker.cross{stroke:#333333;}#mermaid-svg-doBqpeH35bziDPF8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-doBqpeH35bziDPF8 p{margin:0;}#mermaid-svg-doBqpeH35bziDPF8 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-doBqpeH35bziDPF8 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-doBqpeH35bziDPF8 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-doBqpeH35bziDPF8 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-doBqpeH35bziDPF8 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-doBqpeH35bziDPF8 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-doBqpeH35bziDPF8 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-doBqpeH35bziDPF8 .sequenceNumber{fill:white;}#mermaid-svg-doBqpeH35bziDPF8 #sequencenumber{fill:#333;}#mermaid-svg-doBqpeH35bziDPF8 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-doBqpeH35bziDPF8 .messageText{fill:#333;stroke:none;}#mermaid-svg-doBqpeH35bziDPF8 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-doBqpeH35bziDPF8 .labelText,#mermaid-svg-doBqpeH35bziDPF8 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-doBqpeH35bziDPF8 .loopText,#mermaid-svg-doBqpeH35bziDPF8 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-doBqpeH35bziDPF8 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-doBqpeH35bziDPF8 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-doBqpeH35bziDPF8 .noteText,#mermaid-svg-doBqpeH35bziDPF8 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-doBqpeH35bziDPF8 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-doBqpeH35bziDPF8 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-doBqpeH35bziDPF8 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-doBqpeH35bziDPF8 .actorPopupMenu{position:absolute;}#mermaid-svg-doBqpeH35bziDPF8 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-doBqpeH35bziDPF8 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-doBqpeH35bziDPF8 .actor-man circle,#mermaid-svg-doBqpeH35bziDPF8 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-doBqpeH35bziDPF8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} "GET /mf/search?distance=2000&gender=1""从登录态取当前 userId 与坐标""queryNearUser(lng, lat, distance)""metre / 1000 转千米,构造 Circle""withinSphere 查询 user_location""位置文档列表""userId 列表""findByIds(userIds) 补全资料""UserInfo 列表""性别过滤 + 排除自己 + 组装 VO""List<NearUserVo>"
几个值得注意的边界:
- 坐标从哪来:检索用的圆心不是请求参数,而是服务端根据当前登录用户查出来的最新上报坐标。这意味着没上报过位置的用户,连自己都搜不了。
- 两次跨服务调用:一次查位置(MongoDB),一次查资料(MySQL)。前者返回的是「谁在附近」,后者补的是「他长什么样」,职责严格分离。
- 过滤发生在最后一环:性别筛选和排除自己都在 app-server 层完成,不在数据库层。这决定了返回条数会少于数据库命中的条数。
方案选型的取舍
把「附近的人」做出来,技术上至少有四条路可走,各自的代价差别很大:
| 方案 | 实现方式 | 优点 | 代价 |
|---|---|---|---|
| MySQL 矩形粗筛 | WHERE lat BETWEEN ... AND lng BETWEEN ... |
无需引入新组件 | 结果是矩形不是圆,需内存二次过滤;数据量大时全表扫 |
| MySQL + 空间扩展 | ST_Distance_Sphere / POINT 类型 |
仍在 MySQL 内 | 版本依赖强,索引效果有限,函数计算无法走索引 |
| Redis GEO | GEOADD / GEOSEARCH |
内存级速度,天然支持距离排序 | 需额外维护一份位置数据,持久化与一致性要自己兜 |
| MongoDB 2dsphere | GeoJsonPoint + withinSphere / $geoNear |
原生球面索引,写入即建索引,支持排序升级 | 引入 MongoDB 组件,需处理跨库一致性 |
项目最终选 MongoDB,核心原因是它把「球面距离」这件事下沉到了索引层:写入时坐标就被组织成可检索结构,查询时一次圈定,不需要把候选行拉到内存里逐个算距离。Redis GEO 在性能上更优,但位置数据本身需要持久化留存(用于后续的「附近的动态」等扩展),MongoDB 的文档模型更贴合。
本篇在整章中的位置
这一章按「先全景、再拆解」的顺序组织:
- 本篇(功能概述与分析):讲清楚要做什么、整体怎么搭、有哪些坑
- 上报地理位置:解决「位置怎么进来」,包括上报时机、存储结构、一用户一条的覆盖更新
- 检索附近的人 :解决「怎么圈出来」,包括
withinSphere的构造、单位换算、结果处理 - 扩展与优化 :解决「怎么做得更好」,包括
$geoNear排序、分页、附近的动态
读完本篇应该能回答三个问题:这个功能的产品边界在哪、数据从哪来到哪去、哪些地方最容易出错。带着这三个问题的答案往下读,每一篇都能对上号。