怎么用 这是格式与深度的参考,不是让你照抄的模板。数字全部是示例,直接抄进简历一问就穿——必须换成你自己的真实数据,而且要能说清是怎么测出来的。下面每个模块都有「数字是怎么测的」和「面试追问」两段,写不出那两段的模块就不要往简历上写

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

项目背景设定 本地生活到店业务的服务端,Java + Spring Cloud + MySQL + Redis + Elasticsearch。核心链路是找店 → 买券 → 到店核销 → 商家结算。十万级门店、跨多个城市与国家,商家自己会核对每一笔钱。
为什么选这三个模块 本地生活后端的三个真正难点:LBS 检索(坐标系、距离排序、分页稳定性都有坑,而且是纯技术题)、核销(一次操作对应真实的商品交付,出错就是资损或纠纷)、结算对账(商家最关心钱,算错一分钱就是信任崩塌,也是最能体现业务闭环意识的部分)。

模块一:门店与附近检索

  1. 门店与附近检索(网格预筛 + 地理距离过滤 + 游标分页)★★★
    简历这样写 门店 LBS 检索服务(Elasticsearch geo_point + GeoHash 网格预筛 + 游标分页 + 状态缓存):附近门店检索走「网格预筛 → 地理距离过滤 → 多维度排序」三段,坐标系统一在服务端转换避免混用导致的位置偏移;分页改为以首次定位为锚点的游标分页,用户走动时不再出现重复与遗漏;营业状态与可售库存按门店维度缓存、不进检索索引。压测 500 QPS 下附近门店接口 P95 约 90ms(十万级门店),坐标系偏移类问题改造后未再出现。
    展开完整拆解
    为什么要这么设计

    第一版是用 MySQL 做的:门店表存经纬度,查询时先用一个矩形范围过滤(lat between ? and ? and lng between ? and ?),把结果查出来在内存里算精确距离再排序。四个问题。

    一是矩形不是圆。用经纬度区间框出来的是个矩形,四个角上的门店距离超出了要求的半径,而正南正北方向又会漏掉一些。更麻烦的是经度对应的实际距离随纬度变化——同样 1 度经度,赤道附近是 111 公里,高纬度地区只有几十公里,固定的经纬度偏移量在不同城市误差完全不同。

    二是没法分页。距离是在内存里算的,数据库层面没有距离这个字段,所以只能把范围内所有门店全查出来再排序取前 20。密集城区一个矩形里有几千家店,每次翻页都要重查重排。

    三是坐标系混用导致位置偏移。定位 SDK 返回的坐标、地图组件用的坐标、商家录入时用的坐标可能是不同坐标系,混在一起用会有几百米的系统性偏移。这类问题肉眼很难发现——地图上看起来门店就在那条街上,只是偏了一点,直到用户反馈「导航到的地方不对」。

    四是分页错乱。用户边走边翻页,第二页请求时定位已经变了,以新位置重新算距离排序,第一页看过的店又出现在第二页

    所以四个改造:用支持地理类型的检索引擎做真正的距离过滤坐标系在服务端统一转换分页以首次定位为锚点高频变动的状态字段不进索引

    整体链路
    门店数据入库 │ ├─ 商家录入地址 → 地理编码得到坐标 + 坐标系标识 │ ├─ 服务端统一转换到内部坐标系(只存一种,绝不混存) │ ├─ 写门店主表(MySQL,权威数据) │ └─ binlog 订阅 → 写 ES 检索索引 索引只放「用于检索排序」的字段: 坐标 / 城市 / 品类 / 评分 / 综合质量分 / 上架状态 不放:营业状态、剩余库存、今日排队数(变动太频繁) 附近检索 │ ├─ 入参:用户坐标 + 坐标系 + 半径 + 品类 + 排序方式 + 游标 │ ├─ 坐标归一:按入参声明的坐标系转换到内部坐标系 │ ├─ 第一段 网格预筛(GeoHash 前缀 / ES 内部网格索引) │ 作用:把候选集从十万级缩到千级,代价极低 │ ├─ 第二段 精确距离过滤(geo_distance filter,走 filter 不打分) │ 叠加业务过滤:城市 / 品类 / 已上架 │ ├─ 第三段 排序 │ 纯距离排序:按距离升序 + 门店 ID 兜底(保证顺序稳定) │ 综合排序:距离分 × 评分 × 质量分(各自归一化后加权) │ ├─ 游标分页:游标里编码「锚点坐标 + 上页末尾的距离与门店 ID」 │ 下一页用锚点坐标算距离,不用当前实时坐标 │ └─ 补实时状态:批量从缓存取营业状态与库存 已打烊 / 售完 的门店在这一步打标记而非剔除 (剔除会导致每页条数不一致,分页更难对齐) 跨城市与跨国 ├─ 城市字段做硬过滤:不返回跨城市的店(哪怕物理距离近) ├─ 营业时间按门店所在时区判断,不用服务器时区 └─ 距离展示单位按用户地区(公里 / 英里)在展示层换算
    分步拆解
    1. 坐标系必须在入口统一,这是最容易埋雷的一步。接口入参要显式声明坐标系,服务端转换成内部统一的一种再使用。数据库里绝不混存两种坐标系——一旦混了,后期无法分辨哪条是哪种,只能靠人工核对。门店录入时的地理编码结果也要标明坐标系
    2. 网格预筛加精确过滤是两段而不是一段。网格(GeoHash 或引擎内部的网格索引)负责快速把候选集缩小,它是近似的、有边界误差的;然后在小候选集上做精确的球面距离计算。只用网格会有边界误差,只用精确计算会扫全表,两段配合才既快又准。
    3. 地理过滤要走 filter 不走 query。「距离是否在 3 公里内」是布尔判断,没有「更相关」的概念,放进打分逻辑纯属浪费,而且 filter 的结果可以被缓存。
    4. 排序必须有 tie-breaker。两家店距离完全相同(同一栋楼里的两家店)时,如果只按距离排序,两次查询的返回顺序可能不同,分页就会出现重复或遗漏。加门店 ID 作为第二排序键,让顺序严格确定。
    5. 游标里要存锚点坐标,这是分页稳定的关键。第一次请求记下用户坐标作为锚点,后续翻页都用这个锚点算距离,而不是用当前的实时坐标。用户边走边翻页时,看到的仍然是「以刚才那个位置为中心」的连续列表。如果用户主动下拉刷新,才更新锚点重新开始。
    6. 营业状态和库存不进检索索引。它们一天变好几次甚至每笔订单都变,进索引意味着高频更新,十万门店的写入压力不划算。做法是索引只返回门店 ID 和静态字段,实时状态从缓存批量补
    7. 已打烊的店打标记而不是剔除。如果在补状态阶段把打烊的店剔除掉,每页返回的条数就不固定(要 20 条返回 17 条),前端分页逻辑会很难写,而且用户可能就是想看看附近有哪些店明天再去。标记为「休息中」并排到后面,比直接消失更好。
    8. 城市要做硬过滤。两个城市交界处,物理距离很近但跨城市的店通常不该出现(行政区划、配送范围、运营归属都不同)。按城市硬过滤,不要只靠距离。
    9. 营业时间用门店时区判断。跨国业务里服务器时区和门店时区不同,用服务器时间判断营业状态会全错。门店表要存时区标识,判断时转换。
    关键决策与取舍

    为什么不用 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%」。那是排序策略和门店供给的结果,不是检索技术的成果。

    面试追问
    Q:GeoHash 是什么原理?它有什么问题? A:原理是把二维的经纬度编码成一维字符串:不断把地球对半分(先按经度分左右、再按纬度分上下,交替进行),每次分割产生一个二进制位,最后编码成 base32 字符串。前缀越长表示的网格越小、精度越高,而且前缀相同的两个点必然在同一个网格内——这让「找附近」变成了字符串前缀匹配,可以直接用 B+ 树索引。主要问题有两个。一是边界问题:两个物理距离几十米的点,如果在网格边界两侧,前缀完全不同,只查本格就会漏掉。解法是同时查周围 8 个相邻网格,一共 9 格,再做精确距离过滤二是网格大小不均匀:同样长度的前缀,在不同纬度对应的实际面积不同(越靠近极地经线越密)。所以 GeoHash 适合做快速预筛,不能直接拿它的结果当最终答案。
    Q:用户一边走一边翻页,怎么保证不重复不遗漏? A:把首次请求的坐标作为锚点存进游标,后续翻页都用锚点算距离,不用实时坐标。这样整个分页会话的排序基准是固定的,列表是连续的。同时排序必须有 tie-breaker——距离相同的门店用门店 ID 排序,否则相同距离的几家店顺序不确定,翻页还是会乱。游标里存「锚点坐标 + 上一页最后一条的距离和门店 ID」,下一页查「距离更大,或距离相同但 ID 更大」的。用户主动下拉刷新时才更新锚点重新开始,并且要给出提示(比如「已更新到当前位置」),让用户知道基准变了。
    Q:营业状态、剩余库存这些为什么不放进 ES?那不是没法按「营业中」筛选了吗? A:确实牺牲了这个筛选能力,这是明确的取舍。不放的原因是更新频率——营业状态一天变几次、库存每笔订单都变,十万门店的高频更新会让 ES 写入压力很大,而 ES 的更新实际是「标记删除加重新写入」,代价比想象的大。做法是索引返回门店 ID,实时状态从缓存批量补,然后在结果里打标记并把打烊的排到后面如果业务强要求「只看营业中」这个筛选,折中方案是把「营业时间段」这个相对静态的字段放进索引(营业时间表一周才改一次),查询时用当前时间去匹配时间段——这样既能筛选又不用高频更新。区分「静态的营业时间规则」和「动态的当前是否开门」,是这道题的关键。
    Q:如果门店从十万涨到一亿(比如全球所有 POI),方案要怎么变? A:单一 ES 索引撑不住,要做地理分片——按城市或者按大区把索引拆开,查询时根据用户位置路由到对应的索引,跨边界时查相邻的几个。这样每个索引的数据量可控。其次是分层缓存:热门城市、热门商圈的检索结果缓存起来(用网格 ID 加筛选条件做缓存键),因为大量用户在同一商圈搜同样的东西。再者要考虑冷热分离,长期没有交易的门店可以放到冷索引,默认不查。但更重要的判断是:一亿 POI 通常意味着业务形态变了(从「平台商家」变成「全网地点」),那时可能应该用专门的地理数据库或者引入更成熟的 LBS 基础服务,而不是继续堆 ES。能说出「到某个量级我会换方案而不是优化现方案」,比硬答优化技巧好。

模块二:券码与核销服务端

  1. 券码与核销服务端(动态码签名 + 状态机 + 并发冲突控制)★★★
    简历这样写 券码与到店核销服务端链路(HMAC 动态码 + 幂等键 + 状态机条件更新 + 核销流水):核销以服务端状态机为唯一裁定,券码采用带时效签名的动态码防截图转发;核销、退款、过期三类操作对同一券码的并发用条件更新串行化,商家端重试与断网补提交由幂等键消化;结果分成功 / 已核销 / 无效 / 未知四态并回传上次核销信息。压测 1000 QPS 核销下多轮未出现重复核销,动态码有效窗口 60 秒,核销接口 P95 约 50ms
    展开完整拆解
    为什么要这么设计

    核销是本地生活里唯一一个「一次接口调用对应一次真实物品交付」的动作,出错的后果非常具体:重复核销会多扣用户的券,核销失败但商家已经把东西给了就是商家亏损。第一版的问题集中在四点。

    一是静态码可以截图转发。券码是固定字符串生成的二维码,用户截图发给朋友,朋友去另一家分店照样能核销。券的所有权失去了控制。

    二是核销和退款并发导致资损。用户在 App 上点退款,同时店员在扫码核销。两个操作都读到「未使用」状态,然后一个改成已退款、一个改成已核销,结果是券退了钱但东西也拿走了

    三是商家端弱网重试造成重复核销。店内网络差,商家端提交核销超时,店员又点一次,服务端处理了两次,用户的券被扣了两张。

    四是失败原因太笼统。返回一个「核销失败」,店员完全不知道该怎么办——是券已经用过了?过期了?不适用这家店?不同原因对应完全不同的处理动作,笼统的错误只会导致店员反复尝试和顾客争执。

    所以四个设计:动态码带时效签名所有状态流转用条件更新串行化幂等键消化重试结果分四态且带上下文信息。核心原则是服务端是唯一裁定者,客户端的任何判断都只是优化

    整体链路
    动态码生成(用户端每隔一段时间刷新) │ ├─ 服务端按请求生成:券实例 ID + 时间戳 + 随机盐 │ ├─ 用密钥做 HMAC 签名,拼成码串(签名不可逆,不用可逆加密) │ └─ 返回给用户端展示,客户端到期自动向服务端换新码 有效窗口约 60 秒,截图几十秒后即失效 商家扫码核销 │ ├─ 商家端扫到码串 → 本地先做格式校验(能本地拒绝的就别发请求) │ ├─ 提交核销:码串 + 商家 ID + 门店 ID + 幂等键 │ 幂等键 = 券实例 + 门店 + 本次操作 ID(客户端生成并持久化) │ ├─ 服务端验签 + 校验时间窗口(允许一定时钟偏差) │ 验签失败 → 无效码(可能是伪造或过期截图) │ ├─ 幂等检查:该幂等键是否已处理 → 已处理直接返回原结果 │ ├─ 状态机条件更新(这一步是并发正确性的唯一保证) │ update coupon_instance │ set status = '已核销', verify_time = now(), store_id = ? │ where id = ? and status = '未使用' │ │ │ ├─ 影响行数 1 → 核销成功 │ └─ 影响行数 0 → 查当前状态给出精确原因 │ 已核销 → 返回上次核销的时间与门店 │ 已退款 → 返回「该券已退款」 │ 已过期 → 返回过期时间 │ ├─ 写核销流水(独立表,一次核销一条,是结算与对账的唯一依据) │ └─ 返回四态结果 成功 / 已核销(带上次信息)/ 无效(带具体原因)/ 未知 并发冲突:核销 vs 退款 vs 过期 └─ 三者都用条件更新抢同一行的状态 谁的 where 条件先满足谁成功,后来的影响行数为 0 退款成功后核销会失败并提示「已退款」 核销成功后退款会失败并提示「已使用,不可退」 商家端断网补提交(服务端半边) ├─ 商家端本地队列保存「待确认」的核销,恢复后重放 ├─ 服务端靠幂等键识别:已处理过的返回原结果,不重复扣券 └─ 若服务端压根没收到过 → 正常执行一次核销
    分步拆解
    1. 动态码用签名而不是加密。签名(HMAC)是单向的,服务端只需验证「这个码是我签发的且没被改过」;用可逆加密的话密钥泄露就能伪造任意券码,风险高得多。码串里也不要放敏感信息,只放券实例 ID 这类无意义标识。
    2. 时间窗口要容忍时钟偏差。用户手机时间可能不准,但码是服务端生成的所以时间戳可信;真正要容忍的是网络传输和扫码操作的耗时。窗口太短会导致正常核销失败(用户举着手机、店员对焦花了十几秒),太长则截图转发的防护变弱。60 秒是个平衡点。
    3. 幂等键必须由客户端生成并持久化。如果服务端生成,客户端重试时无法带上同一个键。客户端在发起请求之前就把幂等键写进本地队列,这样即使应用崩溃重启,重放的还是同一个键。顺序是「先记录后执行」。
    4. 状态机条件更新是并发正确性的唯一保证。where status = '未使用' 让数据库的行锁替我们做串行化,不需要额外的分布式锁。影响行数就是结果:1 是成功,0 是被别人抢先了。绝对不能先 select 判断状态再 update,那中间的窗口就是并发漏洞。
    5. 影响行数为 0 时必须回查当前状态给出精确原因。这一步是为了店员能处理。「已核销」要返回上次核销的时间和门店,店员据此和顾客沟通(可能是顾客自己忘了,也可能是券被转发了)。笼统的失败信息会让店员反复尝试。
    6. 核销流水必须独立成表,一次核销一条。券实例上的 status 只是当前状态,会被后续操作覆盖;流水是不可变的事实记录,是结算和对账的唯一依据。没有流水表,商家问「这个月我核销了多少笔」就只能从券表反查,退款和撤销后就对不上了。
    7. 「已核销」和「无效」必须是不同的返回态。「已核销」说明券真实存在且用过了,是正常业务结果;「无效」说明码本身有问题(伪造、过期、不适用本店)。把它们合并成「失败」会让商家端无法给出正确指引。
    8. 不能用前端定位判断「用户是否在店里」。定位可以被模拟软件伪造。真正的物理约束来自扫码这个动作本身——商家的扫码设备在店里。如果业务需要位置校验,服务端可以把定位作为风控信号(异常时告警),但不能作为核销的准入条件。
    9. 要防商家自己刷券套现。商家掌握扫码设备,理论上可以拿到用户券码后自行核销、或者和黄牛配合批量核销未真实消费的券。服务端要做风控:单店核销速率异常、非营业时间核销、同一用户在多店短时间核销、核销量与历史均值严重偏离,这些都要监控并进入人工审核。这一条是很多人想不到的,能主动提会明显加分。
    关键决策与取舍

    动态码、一次性码、反向扫码三种方案的取舍。一次性码(每次生成新码,用完即废)最安全但体验差(用户每次要重新获取);反向扫码(商家出码、用户扫)物理上最可靠(用户必须在店内看到屏幕),但要求商家有屏幕设备,很多小商家没有;动态码是折中——用户展示码、几十秒刷新,覆盖所有商家形态且防住了截图转发。我们默认动态码,对高价值券种额外要求反向扫码。按券的金额分档选方案,而不是一套打天下。

    动态码依赖网络刷新,弱网下用户可能刷不出码。这是明确的代价。缓解手段:码的有效期比刷新周期长一些(比如 60 秒有效、45 秒刷新,留出容错)、提前预取下一个码缓存在本地完全无网时提供券码文本让商家手动输入(走同样的服务端校验,只是跳过扫码)。手动输入这个兜底很重要,它应对的是「摄像头坏了」「码磨损」「彻底无网」这类物理故障。

    不做真正的离线核销,这是硬性结论。商家一定会提「网断了我也要能核销」,但客户端无法验证券的有效性和是否已被使用,如果允许离线判定成功,同一张券可以在多台设备上各核销一次,恢复后才发现冲突而东西已经交付了。我们能提供的是「操作不阻塞」——商家端可以连续扫码进本地队列,界面明确标注「待确认」,商家按券的金额自行决定是否先交付。要把「不能做」转化成「能做的替代方案加明确的风险归属」。

    踩过的坑:退款和核销并发,两边都成功了。最初退款走的是「查状态为未使用 → 调支付退款 → 更新状态为已退款」,核销走的是另一套类似逻辑,两者都在「查」和「改」之间留了窗口。真实发生过:用户点退款的同时店员扫码,退款成功打了钱,核销也成功了,商家还结算到了这笔。修法是所有状态流转统一走条件更新,并且退款要先把状态改成「退款中」占位再调支付——占位成功才继续,这样核销会因为状态不是「未使用」而失败。「先占位再执行外部调用」是这类跨系统操作的通用模式。

    没做的部分:没做券的部分核销(一张券分多次使用,比如十次卡)。它需要在券实例上维护剩余次数并处理并发扣次,模型复杂度明显上升,当时业务上都是一次性券,没做。

    数字是怎么测的

    重复核销验证:限速模拟弱网,对同一张券用同一个幂等键并发提交 10 次,再用不同幂等键并发提交 10 次,检查核销流水条数和券状态。两种都要测:同幂等键考验的是幂等表,不同幂等键考验的是状态机条件更新。跑多轮,改造后未出现重复核销。

    并发冲突验证:构造「核销与退款同时发起」的场景,用并发工具让两个请求尽可能同时到达,跑上百轮,检查是否只有一个成功、另一个是否返回了正确的原因。这是这个模块最该测的用例,也是面试官最可能追问的场景。

    核销接口 P95:压测 500 / 1000 QPS,要区分「成功核销」和「已核销被拒」两种路径——后者多了一次回查状态,耗时略高。混在一起算会掩盖真实情况。

    不要报「核销成功率」。核销失败的主要原因是券本身已用过或过期,这是正常业务结果不是系统故障,把它算进成功率会得出一个没有意义的数字。可以报的是「因系统原因导致的核销失败次数」(超时、服务异常),这个口径清晰,而且用绝对数报。

    动态码窗口 60 秒是怎么定的:实测「用户点开券码 → 店员完成扫码」的耗时分布,取覆盖绝大多数正常操作的时长再留余量。这类参数要能说出实测依据,不能说「我们就设了 60 秒」。

    面试追问
    Q:用户把券码截图发给朋友,朋友去核销,怎么防? A:静态码防不住,必须用动态码——码串里包含时间戳并用密钥签名,服务端校验签名和时间窗口,截图几十秒后就失效。更强的方案是反向扫码:商家屏幕上显示动态码,用户扫商家的码,这样用户物理上必须在店内。我们的做法是按券的金额分档:普通券用动态码(覆盖所有商家,包括没有屏幕的小店),高价值券要求反向扫码。还有几个辅助手段:核销时给用户推一条确认消息(用户发现不是自己核销的可以立刻申诉)、服务端做异常检测(同一账号短时间在相距很远的门店核销)。要说明的是这些都是提高伪造成本而不是彻底杜绝,纯软件手段做不到绝对防护。
    Q:用户点退款的同时店员在扫码核销,你怎么保证不会两个都成功? A:靠状态机的条件更新,让数据库行锁做串行化。两个操作都要抢同一行券实例的状态:核销是 where status = '未使用' 改成已核销,退款是 where status = '未使用' 先改成「退款中」。只有一个能成功,另一个影响行数为 0,然后回查当前状态给出精确提示——核销失败会告诉店员「该券正在退款」,退款失败会告诉用户「该券已使用,不可退」。关键细节有两个:一是绝不能先 select 判断再 update,中间的窗口就是漏洞;二是退款要先占位再调支付渠道,因为调支付是外部调用、耗时长,如果先调支付成功再改状态,这段时间里核销就能插进来。「先占位、再做外部调用、最后确认」是跨系统操作的标准顺序。
    Q:商家端店内网络很差,提交核销超时了,店员又点了一次,会怎样? A:不会重复核销,靠客户端生成的幂等键。商家端在发起请求之前就把「券码 + 门店 + 本次操作 ID」写进本地队列,重试时带同一个键。服务端查幂等表,已处理过的直接返回第一次的结果(成功就是成功,不重复扣券)。注意幂等键必须客户端生成——服务端生成的话客户端重试时带不上同一个键。还有个更麻烦的情况:请求实际成功了但响应丢在路上,客户端认为超时。这时客户端不能显示「失败」(会引导店员重扫),要显示「待确认」,然后用幂等键去查询服务端的处理结果。这和支付链路的「未知态」处理原则完全一致:超时只意味着不知道结果,唯一正确的动作是查询而不是判定。
    Q:商家可以自己扫用户的券套现吗?你怎么防? A:技术上无法完全阻止——商家掌握扫码设备,只要拿到用户的券码就能核销。所以这是风控问题不是权限问题。服务端要做几层监控:核销速率异常(单店在几分钟内核销远超历史峰值)、非营业时间核销同一用户在多个门店短时间连续核销核销量与门店历史均值严重偏离券的购买与核销时间间隔异常短(刚买就核销可能是刷单)。命中规则的进入人工审核,严重的暂缓结算(这是最有效的手段,钱没到手商家就没动机)。另外核销时给用户推确认消息,让用户能发现并申诉。结算前的审核期本身就是最大的防线——这也是为什么结算要有账期而不能实时到账,下一个模块会讲。

模块三:商家结算与对账

  1. 商家结算与对账(账期跑批 + 分账明细 + 三方对账 + 差异挂账)★★★
    简历这样写 商家结算与对账链路(账期跑批 + 分账规则 + 三方对账 + 红冲机制):按账期汇总核销流水生成结算单,手续费、平台补贴、税费按规则拆成分账明细,每笔结算金额可逐笔追溯到核销流水;跨账期退款用红冲而非修改历史账单;每日与支付渠道、平台账务、商家账单三方对账,差异走挂账与人工处理、不自动改数。日终跑批处理十万级流水约 8 分钟,对账差异每日在个位数以内,打款重复由幂等键消化。
    展开完整拆解
    为什么要这么设计

    结算是本地生活里最不能出错的部分,因为商家会拿计算器逐笔核对。少算一分钱,商家会认为平台在坑他,信任一旦崩塌很难挽回。第一版是一条聚合 SQL 算出每个商家应付多少,然后打款。问题在第一个月就全暴露了。

    一是不可追溯。商家问「这个月为什么比上个月少了三百块」,我们只能给出一个总数,说不清是哪几笔、哪些扣了手续费、哪些被退款冲减了。结算必须能下钻到每一笔。

    二是跨账期退款算不清。用户上个月核销、这个月退款,上个月的账单已经打款了。直接改上个月的账单是错的(钱已经付了,账实不符),不处理也是错的(多付了商家钱)。

    三是没有拆分明细。商家看到的是一个数,不知道其中平台抽了多少佣金、平台补贴了多少(营销活动里券的成本一部分是平台承担的)。不透明本身就会引起怀疑。

    四是多币种没处理。跨国业务里核销时的汇率和结算时的汇率不同,用哪个汇率算、差额谁承担,最初完全没定义。

    所以设计成:以核销流水为唯一结算依据(不用订单,因为订单不代表已履约)、结算单加分账明细两层(可下钻)、跨账期调整用红冲(不改历史)、汇率在核销时刻快照(金额确定下来)。

    整体链路
    结算依据:核销流水(不是订单,也不是支付记录) │ ├─ 订单支付了但没核销 → 服务未履约,不结算 ├─ 核销了才是商家真正提供了服务 → 才产生应付 └─ 每条流水在核销时刻就固定:金额、币种、汇率快照、券成本归属 日终跑批(按账期,比如自然日或半月结) │ ├─ 1 账期截取:取核销时间落在账期内且状态有效的流水 │ 账期边界必须定死用哪个时间(我们用核销完成时间) │ ├─ 2 按商家分组汇总 │ ├─ 3 拆分账明细(每条流水拆成几行) │ 商家实收 = 券面额 - 平台佣金 - 通道费 │ 平台补贴 = 券面额中平台承担的部分(营销活动配的) │ 税费 = 按商家所在地区规则计提 │ 拆分规则来自规则表,不硬编码 │ ├─ 4 汇总本期红冲(上期已结算但本期发生退款的,负数计入) │ ├─ 5 生成结算单(单头 + 明细行) │ 单头:商家 / 账期 / 应付总额 / 状态 │ 明细:每笔流水一行,可下钻查看拆分 │ ├─ 6 风控拦截:命中风险规则的商家暂缓结算,转人工 │ ├─ 7 发起打款(幂等键 = 结算单号,重跑不会重复打) │ └─ 8 打款结果回写,失败的进重试或人工 跨账期退款:红冲而不是改历史 │ ├─ 上期账单已打款,不能修改(改了账实不符) │ ├─ 本期生成一条负数的红冲明细,冲减本期应付 │ 明细上标注:冲减自哪一期的哪笔流水 │ └─ 若本期应付不足以冲减 → 挂账为负余额,下期继续冲 长期负余额 → 走追偿流程(人工) 每日三方对账 │ ├─ 平台侧:核销流水汇总的应付金额 ├─ 账务侧:账务系统记的应付余额 ├─ 渠道侧:支付渠道的实际打款流水 │ ├─ 三者两两比对,找出差异 │ └─ 差异处理原则:告警 + 挂账 + 人工定位根因 绝不自动改数(自动改会掩盖 bug 且可能改错方向)
    分步拆解
    1. 结算的依据必须是核销流水,不是订单也不是支付记录。用户支付了但没到店核销,商家压根没提供服务,不该结算给他。核销才是履约的凭证。这一条决定了整个数据模型的走向,也是为什么上一个模块要单独建流水表。
    2. 账期边界要定死用哪个时间字段。核销发起时间、核销完成时间、支付时间,三者在跨零点时可能落在不同账期。必须选一个并写进文档,否则跑批的两次实现可能用不同字段,导致同一笔流水在两个账期里都出现或者都不出现。我们用核销完成时间。
    3. 汇率必须在核销时刻快照下来。跨国业务里核销和结算之间隔着几天,汇率会变。如果结算时用当时汇率,商家实收会和他核销时看到的金额不一致,一定会引起投诉。做法是核销流水上记下当时的汇率和折算后金额,结算直接用这个快照值。汇率波动的风险由平台承担,这是业务决策,要说清。
    4. 分账规则放规则表,不要硬编码。佣金比例按品类、商家等级、活动期不同,写死在代码里意味着调整费率要发版。规则表加生效时间范围,并且流水上要记下「用的是哪个版本的规则」——否则规则改了之后无法复算历史账单。
    5. 结算单要有单头加明细两层,且明细能下钻到流水。商家质疑金额时,客服能一路点到「哪一笔核销、面额多少、扣了多少佣金」。这个可追溯性是结算系统的核心价值,不是附加功能。
    6. 跨账期调整一律用红冲,不修改历史账单。已打款的账单是财务凭证,改它就是账实不符。红冲的做法是在当期生成一条负数明细,并注明冲减来源。这样每一期的账单都是自洽且不变的,审计能追溯。
    7. 红冲不足要能挂账。商家本期核销很少但退款很多,应付算出来是负数。这时不能给商家打负数的钱,要挂成负余额结转到下期继续冲减,长期挂账不掉的走人工追偿。
    8. 打款必须幂等。跑批失败重跑是常态(数据问题、超时、人工触发),如果打款没有幂等保护,重跑就是重复打钱。用结算单号作为幂等键,支付渠道侧也要传商户订单号让渠道去重。这是资损风险最高的一步,必须双重保护。
    9. 风控要卡在打款之前。暂缓结算是对刷单套现最有效的手段——钱没到手,作弊的动机就小得多。命中风险规则的商家结算单置为「待审核」而不是直接打款。
    10. 三方对账是必需的,而且差异不能自动修。自动修数会掩盖链路里的 bug,而且如果修的方向错了会造成二次损失。正确做法是告警、输出差异清单、挂账、人工定位根因,修完根因再批量处理。
    关键决策与取舍

    为什么不做实时结算(核销即到账)。商家当然希望立刻拿钱。但有三个硬约束:一是退款窗口未过——用户核销后仍可能因为服务纠纷退款,钱打出去了再要回来极难;二是风控需要观察期,刷单套现的识别依赖一段时间的行为数据,实时结算等于放弃了这道防线;三是成本,每笔核销都调一次打款,通道费和系统开销都远高于批量。所以账期本质上是「风险窗口」而不是「技术限制」,这个认知很重要——有人会以为账期是系统能力不足,其实是业务风控的需要。折中方案是对优质商家缩短账期(比如日结),作为激励。

    跑批的时间窗口与幂等性。日终跑批要在业务低峰完成,十万级流水 8 分钟是可以的,但量涨到百万级就要考虑分片并行(按商家 ID 哈希分片)。并行的前提是每个商家的结算相互独立,这一点成立,所以分片很自然。更重要的是跑批必须可重入——中途失败重跑不能产生重复结算单,用「商家 + 账期」的唯一索引保证。

    踩过的坑:跑批重跑导致重复打款。某次跑批因为数据库超时中断,运维手动重跑,结果一部分商家收到了两笔钱。原因是结算单生成有唯一索引保护,但打款那一步没有幂等键——第一次跑批已经发起了打款但状态还没回写,重跑时看到状态是「待打款」又发起了一次。修法是打款前先把状态条件更新为「打款中」,成功才调渠道,并且传结算单号给渠道做去重这个坑的教训是:涉及资金的每一步都要独立幂等,不能依赖上游的幂等。

    踩过的坑二:账期边界用了核销发起时间,导致跨零点的流水两边都算。核销发起在 23:59:58、完成在 00:00:01,两次跑批分别按不同字段筛选,这笔流水在两期都被算进去,商家多拿了一笔。修法是全链路统一用核销完成时间,并且写进规范文档「时间字段的选择」在跑批里是个隐蔽但致命的细节。

    没做的部分:没做多级分账(比如商家下面还有加盟商、区域代理要分成)。这需要分账链路支持树形结构和逐级抽成,复杂度高一个量级。当时业务是单级商家,没做,但要能说出它是已知的扩展方向。

    数字是怎么测的

    跑批耗时:用十万级流水的数据集跑,记录从任务开始到结算单全部生成的墙钟时间。约 8 分钟。必须说明流水量、商家数、是否并行、机器规格——「8 分钟」如果不说处理了多少数据就没有意义。另外要报出分片并行后的表现,说明这个方案能往上扩。

    对账差异:每日对账发现的差异记录数,个位数以内。用绝对数而不是「准确率 99.99%」——十万笔的分母下任何比率都好看得没有意义,反而掩盖了「到底有几笔要人工处理」这个真正重要的信息。还要能说出差异的典型原因(渠道打款延迟跨天、退款在对账时点之后发生),说得出原因才说明真在处理这些差异。

    可追溯性怎么验证:随机抽几个结算单,从总额一路下钻到每笔核销流水,检查明细求和是否等于单头总额,以及每笔的佣金计算是否符合当时生效的规则版本。把这个做成自动化校验:每次跑批后自动抽样校验明细与单头的一致性,不一致直接告警并阻止打款。

    不要报「结算准确率 100%」或「零资损」。绝对化断言一问就穿,而且只要有一笔差异就被推翻。正确表述是「差异每日个位数、均在下一账期前处理完毕、可逐笔追溯」这种可核查的描述

    面试追问
    Q:用户上个月核销,这个月退款,上个月的钱已经打给商家了,怎么处理? A:红冲,不改历史账单。上期账单已经打款,是财务凭证,修改它会导致账实不符、审计无法追溯。做法是在当期生成一条负数的红冲明细,注明「冲减自上期某笔流水」,从本期应付里扣掉。如果本期应付不够冲(商家这个月生意差但退款多),就挂成负余额结转下期继续冲减;长期挂着冲不掉的走人工追偿流程。关键点有三个:红冲明细必须能追溯到原始流水;每一期的账单生成后就不可变;负余额要有监控和上限(挂得太多说明这个商家有问题,可能要暂停合作)。
    Q:商家说结算金额和他自己算的不一样,你怎么排查? A:靠可下钻的明细,这也是为什么结算单必须做成单头加明细两层。排查顺序:先对笔数——商家认为核销了 100 笔,我们的流水是 98 笔,差的两笔可能是核销失败但商家以为成功了(这时要看核销流水的原始记录和返回结果);再对单笔金额——面额对不对、佣金比例是否用了正确的规则版本、有没有平台补贴的部分商家不知道;再看红冲——很多时候差额就是上期的退款冲减,商家没算这一项;最后看汇率(跨境场景)。能快速定位的前提是每笔流水都记了「当时用的规则版本和汇率快照」,没记这些就只能靠人肉复算,说不清。
    Q:跑批任务失败了重跑,会不会重复给商家打钱? A:必须不会,这是资损风险最高的地方,要两层保护第一层是结算单的唯一索引(商家 + 账期),重跑时生成结算单会撞索引,说明已生成,跳过或者复用。第二层是打款的幂等——发起打款前先用条件更新把状态从「待打款」改成「打款中」,只有改成功的才真正调渠道;同时把结算单号作为商户订单号传给支付渠道,让渠道侧也做去重。我们踩过这个坑:早期只有第一层,跑批中断时结算单已生成、打款已发起但状态没回写,重跑就重复打了。教训是涉及资金的每一步都要独立幂等,不能假设上游已经幂等了。另外跑批要可重入且有明确的断点,最好按商家分片,失败只重跑失败的分片。
    Q:对账发现差了 50 块钱,你会直接把数据改对吗? A:不会。两个原因。一是自动或手动改数会掩盖根因——差 50 块说明链路某处有 bug(可能是某类流水没被算进去、某个规则算错、某笔退款没冲减),改了这次下次还会差,而且越往后越难追。二是改错方向会造成二次损失——不知道到底是少算了还是多算了就动手,可能把小问题变成大问题。正确流程是:告警 → 输出差异清单(具体哪几笔、差在哪一方)→ 挂账(先不打这部分钱)→ 人工定位根因 → 修根因 → 再批量处理这些差异另外要有独立的对账监控看趋势:差异笔数如果从每天个位数涨到几十笔,说明有新引入的 bug,这比单看某一天的绝对值更能早发现问题。财务数据的处理原则是「宁可挂着不处理,也不能猜着改」。

没有匹配的内容,换个关键词试试。

项目拆解 · 本地生活履约(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据