第一版是用 MySQL 做的:门店表存经纬度,查询时先用一个矩形范围过滤(lat between ? and ? and lng between ? and ?),把结果查出来在内存里算精确距离再排序。四个问题。
一是矩形不是圆。用经纬度区间框出来的是个矩形,四个角上的门店距离超出了要求的半径,而正南正北方向又会漏掉一些。更麻烦的是经度对应的实际距离随纬度变化——同样 1 度经度,赤道附近是 111 公里,高纬度地区只有几十公里,固定的经纬度偏移量在不同城市误差完全不同。
二是没法分页。距离是在内存里算的,数据库层面没有距离这个字段,所以只能把范围内所有门店全查出来再排序取前 20。密集城区一个矩形里有几千家店,每次翻页都要重查重排。
三是坐标系混用导致位置偏移。定位 SDK 返回的坐标、地图组件用的坐标、商家录入时用的坐标可能是不同坐标系,混在一起用会有几百米的系统性偏移。这类问题肉眼很难发现——地图上看起来门店就在那条街上,只是偏了一点,直到用户反馈「导航到的地方不对」。
四是分页错乱。用户边走边翻页,第二页请求时定位已经变了,以新位置重新算距离排序,第一页看过的店又出现在第二页。
所以四个改造:用支持地理类型的检索引擎做真正的距离过滤、坐标系在服务端统一转换、分页以首次定位为锚点、高频变动的状态字段不进索引。
为什么不用 Redis 的 GEO 命令。Redis 的 GEOSEARCH 性能极好,如果只按距离找附近的点,它是最优选择。但我们的查询必须叠加多个业务过滤——品类、上架状态、城市、评分门槛,还要支持综合排序。Redis GEO 做不到这些,只能查出来再回源过滤,那么「查出来」的量就无法控制(半径内可能有几千家店)。所以选了 ES:地理能力够用,而且过滤和排序的表达能力强得多。如果业务简单到只需要「找最近的 20 家」,Redis GEO 是更轻的方案,不必上 ES。
距离绝对不能让前端算。前端算距离有三个问题:坐标系可能不一致(偏移)、排序结果必须和服务端分页一致(否则分页错乱)、而且距离会被用于业务判断(比如「是否在配送范围内」),前端算的值不可信。服务端算好距离一起返回,前端只负责显示。
精度的取舍要说清楚。球面距离计算有多种公式,简化公式在短距离下误差几十米。几十米误差对「附近门店」完全无所谓(用户不会在意 1.2 公里还是 1.23 公里),但对「是否在门店 100 米内」这类判断就不行。我们的做法是展示用近似值,业务判断用精确计算,两者分开。
踩过的坑:GeoHash 边界导致近的店搜不到。GeoHash 是把地球划分成网格,前缀相同的点在同一网格内。问题是两个物理距离只有几十米的点,如果正好在网格边界两侧,GeoHash 前缀完全不同。只查用户所在网格的话,马路对面的店搜不到。正确做法是查用户所在网格加周围 8 个相邻网格(共 9 格),再做精确距离过滤。这是 GeoHash 应用的经典陷阱,面试常考。
踩过的坑二:分页用了实时坐标,用户走动时列表错乱。用户在地铁上刷附近门店,每次翻页定位都变了,服务端按新坐标重排,导致第一页的店在第三页又出现,中间有些店永远看不到。修法就是游标里存锚点坐标。这个坑的通用教训是:分页的排序基准必须在整个分页会话内保持不变,不管基准是时间、分数还是坐标。
没做的部分:没做按实际路径距离(步行或驾车距离)排序。直线距离和实际步行距离差别可能很大(隔着一条河或者高架)。这需要接路径规划服务,每个候选点都要算一次,成本很高,只在少数场景(比如配送范围判定)值得做。
接口 P95:用十万级门店的数据集(按真实城市分布造,密集城区门店多、郊区少),压测 100 / 300 / 500 QPS。查询坐标要覆盖密集区和稀疏区——密集区候选集大、耗时长,只压稀疏区数字会好看得不真实。500 QPS 下 P95 约 90ms。要说明门店总量、ES 分片数、集群规格、检索半径,这四个不说延迟数字没有参考价值。
「坐标系偏移未再出现」怎么验证:准备一批已知真实坐标的地点作为测试基准,改造前后各跑一遍,检查返回的距离与实际距离的偏差。改造前有系统性的几百米偏移,改造后偏差在几十米内(属于计算精度范围)。把这批基准点固化成自动化测试用例,防止后续有人又引入混用。
分页稳定性:构造场景测试——模拟用户坐标持续变化的同时连续翻 10 页,统计出现两次以上的门店数和被跳过的门店数。改造前每次翻页都有重复,改造后无重复。这类正确性用确定性用例说明,不要用比率。
不要报「附近门店点击率提升 X%」。那是排序策略和门店供给的结果,不是检索技术的成果。
核销是本地生活里唯一一个「一次接口调用对应一次真实物品交付」的动作,出错的后果非常具体:重复核销会多扣用户的券,核销失败但商家已经把东西给了就是商家亏损。第一版的问题集中在四点。
一是静态码可以截图转发。券码是固定字符串生成的二维码,用户截图发给朋友,朋友去另一家分店照样能核销。券的所有权失去了控制。
二是核销和退款并发导致资损。用户在 App 上点退款,同时店员在扫码核销。两个操作都读到「未使用」状态,然后一个改成已退款、一个改成已核销,结果是券退了钱但东西也拿走了。
三是商家端弱网重试造成重复核销。店内网络差,商家端提交核销超时,店员又点一次,服务端处理了两次,用户的券被扣了两张。
四是失败原因太笼统。返回一个「核销失败」,店员完全不知道该怎么办——是券已经用过了?过期了?不适用这家店?不同原因对应完全不同的处理动作,笼统的错误只会导致店员反复尝试和顾客争执。
所以四个设计:动态码带时效签名、所有状态流转用条件更新串行化、幂等键消化重试、结果分四态且带上下文信息。核心原则是服务端是唯一裁定者,客户端的任何判断都只是优化。
where status = '未使用' 让数据库的行锁替我们做串行化,不需要额外的分布式锁。影响行数就是结果:1 是成功,0 是被别人抢先了。绝对不能先 select 判断状态再 update,那中间的窗口就是并发漏洞。status 只是当前状态,会被后续操作覆盖;流水是不可变的事实记录,是结算和对账的唯一依据。没有流水表,商家问「这个月我核销了多少笔」就只能从券表反查,退款和撤销后就对不上了。动态码、一次性码、反向扫码三种方案的取舍。一次性码(每次生成新码,用完即废)最安全但体验差(用户每次要重新获取);反向扫码(商家出码、用户扫)物理上最可靠(用户必须在店内看到屏幕),但要求商家有屏幕设备,很多小商家没有;动态码是折中——用户展示码、几十秒刷新,覆盖所有商家形态且防住了截图转发。我们默认动态码,对高价值券种额外要求反向扫码。按券的金额分档选方案,而不是一套打天下。
动态码依赖网络刷新,弱网下用户可能刷不出码。这是明确的代价。缓解手段:码的有效期比刷新周期长一些(比如 60 秒有效、45 秒刷新,留出容错)、提前预取下一个码缓存在本地、完全无网时提供券码文本让商家手动输入(走同样的服务端校验,只是跳过扫码)。手动输入这个兜底很重要,它应对的是「摄像头坏了」「码磨损」「彻底无网」这类物理故障。
不做真正的离线核销,这是硬性结论。商家一定会提「网断了我也要能核销」,但客户端无法验证券的有效性和是否已被使用,如果允许离线判定成功,同一张券可以在多台设备上各核销一次,恢复后才发现冲突而东西已经交付了。我们能提供的是「操作不阻塞」——商家端可以连续扫码进本地队列,界面明确标注「待确认」,商家按券的金额自行决定是否先交付。要把「不能做」转化成「能做的替代方案加明确的风险归属」。
踩过的坑:退款和核销并发,两边都成功了。最初退款走的是「查状态为未使用 → 调支付退款 → 更新状态为已退款」,核销走的是另一套类似逻辑,两者都在「查」和「改」之间留了窗口。真实发生过:用户点退款的同时店员扫码,退款成功打了钱,核销也成功了,商家还结算到了这笔。修法是所有状态流转统一走条件更新,并且退款要先把状态改成「退款中」占位再调支付——占位成功才继续,这样核销会因为状态不是「未使用」而失败。「先占位再执行外部调用」是这类跨系统操作的通用模式。
没做的部分:没做券的部分核销(一张券分多次使用,比如十次卡)。它需要在券实例上维护剩余次数并处理并发扣次,模型复杂度明显上升,当时业务上都是一次性券,没做。
重复核销验证:限速模拟弱网,对同一张券用同一个幂等键并发提交 10 次,再用不同幂等键并发提交 10 次,检查核销流水条数和券状态。两种都要测:同幂等键考验的是幂等表,不同幂等键考验的是状态机条件更新。跑多轮,改造后未出现重复核销。
并发冲突验证:构造「核销与退款同时发起」的场景,用并发工具让两个请求尽可能同时到达,跑上百轮,检查是否只有一个成功、另一个是否返回了正确的原因。这是这个模块最该测的用例,也是面试官最可能追问的场景。
核销接口 P95:压测 500 / 1000 QPS,要区分「成功核销」和「已核销被拒」两种路径——后者多了一次回查状态,耗时略高。混在一起算会掩盖真实情况。
不要报「核销成功率」。核销失败的主要原因是券本身已用过或过期,这是正常业务结果不是系统故障,把它算进成功率会得出一个没有意义的数字。可以报的是「因系统原因导致的核销失败次数」(超时、服务异常),这个口径清晰,而且用绝对数报。
动态码窗口 60 秒是怎么定的:实测「用户点开券码 → 店员完成扫码」的耗时分布,取覆盖绝大多数正常操作的时长再留余量。这类参数要能说出实测依据,不能说「我们就设了 60 秒」。
where status = '未使用' 改成已核销,退款是 where status = '未使用' 先改成「退款中」。只有一个能成功,另一个影响行数为 0,然后回查当前状态给出精确提示——核销失败会告诉店员「该券正在退款」,退款失败会告诉用户「该券已使用,不可退」。关键细节有两个:一是绝不能先 select 判断再 update,中间的窗口就是漏洞;二是退款要先占位再调支付渠道,因为调支付是外部调用、耗时长,如果先调支付成功再改状态,这段时间里核销就能插进来。「先占位、再做外部调用、最后确认」是跨系统操作的标准顺序。
结算是本地生活里最不能出错的部分,因为商家会拿计算器逐笔核对。少算一分钱,商家会认为平台在坑他,信任一旦崩塌很难挽回。第一版是一条聚合 SQL 算出每个商家应付多少,然后打款。问题在第一个月就全暴露了。
一是不可追溯。商家问「这个月为什么比上个月少了三百块」,我们只能给出一个总数,说不清是哪几笔、哪些扣了手续费、哪些被退款冲减了。结算必须能下钻到每一笔。
二是跨账期退款算不清。用户上个月核销、这个月退款,上个月的账单已经打款了。直接改上个月的账单是错的(钱已经付了,账实不符),不处理也是错的(多付了商家钱)。
三是没有拆分明细。商家看到的是一个数,不知道其中平台抽了多少佣金、平台补贴了多少(营销活动里券的成本一部分是平台承担的)。不透明本身就会引起怀疑。
四是多币种没处理。跨国业务里核销时的汇率和结算时的汇率不同,用哪个汇率算、差额谁承担,最初完全没定义。
所以设计成:以核销流水为唯一结算依据(不用订单,因为订单不代表已履约)、结算单加分账明细两层(可下钻)、跨账期调整用红冲(不改历史)、汇率在核销时刻快照(金额确定下来)。
为什么不做实时结算(核销即到账)。商家当然希望立刻拿钱。但有三个硬约束:一是退款窗口未过——用户核销后仍可能因为服务纠纷退款,钱打出去了再要回来极难;二是风控需要观察期,刷单套现的识别依赖一段时间的行为数据,实时结算等于放弃了这道防线;三是成本,每笔核销都调一次打款,通道费和系统开销都远高于批量。所以账期本质上是「风险窗口」而不是「技术限制」,这个认知很重要——有人会以为账期是系统能力不足,其实是业务风控的需要。折中方案是对优质商家缩短账期(比如日结),作为激励。
跑批的时间窗口与幂等性。日终跑批要在业务低峰完成,十万级流水 8 分钟是可以的,但量涨到百万级就要考虑分片并行(按商家 ID 哈希分片)。并行的前提是每个商家的结算相互独立,这一点成立,所以分片很自然。更重要的是跑批必须可重入——中途失败重跑不能产生重复结算单,用「商家 + 账期」的唯一索引保证。
踩过的坑:跑批重跑导致重复打款。某次跑批因为数据库超时中断,运维手动重跑,结果一部分商家收到了两笔钱。原因是结算单生成有唯一索引保护,但打款那一步没有幂等键——第一次跑批已经发起了打款但状态还没回写,重跑时看到状态是「待打款」又发起了一次。修法是打款前先把状态条件更新为「打款中」,成功才调渠道,并且传结算单号给渠道做去重。这个坑的教训是:涉及资金的每一步都要独立幂等,不能依赖上游的幂等。
踩过的坑二:账期边界用了核销发起时间,导致跨零点的流水两边都算。核销发起在 23:59:58、完成在 00:00:01,两次跑批分别按不同字段筛选,这笔流水在两期都被算进去,商家多拿了一笔。修法是全链路统一用核销完成时间,并且写进规范文档。「时间字段的选择」在跑批里是个隐蔽但致命的细节。
没做的部分:没做多级分账(比如商家下面还有加盟商、区域代理要分成)。这需要分账链路支持树形结构和逐级抽成,复杂度高一个量级。当时业务是单级商家,没做,但要能说出它是已知的扩展方向。
跑批耗时:用十万级流水的数据集跑,记录从任务开始到结算单全部生成的墙钟时间。约 8 分钟。必须说明流水量、商家数、是否并行、机器规格——「8 分钟」如果不说处理了多少数据就没有意义。另外要报出分片并行后的表现,说明这个方案能往上扩。
对账差异:每日对账发现的差异记录数,个位数以内。用绝对数而不是「准确率 99.99%」——十万笔的分母下任何比率都好看得没有意义,反而掩盖了「到底有几笔要人工处理」这个真正重要的信息。还要能说出差异的典型原因(渠道打款延迟跨天、退款在对账时点之后发生),说得出原因才说明真在处理这些差异。
可追溯性怎么验证:随机抽几个结算单,从总额一路下钻到每笔核销流水,检查明细求和是否等于单头总额,以及每笔的佣金计算是否符合当时生效的规则版本。把这个做成自动化校验:每次跑批后自动抽样校验明细与单头的一致性,不一致直接告警并阻止打款。
不要报「结算准确率 100%」或「零资损」。绝对化断言一问就穿,而且只要有一笔差异就被推翻。正确表述是「差异每日个位数、均在下一账期前处理完毕、可逐笔追溯」这种可核查的描述。
没有匹配的内容,换个关键词试试。
项目拆解 · 本地生活履约(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据