10万QPS如何撑起公交实时到站与ETA

“还有2站,约5分钟”不是一次距离换算的结果,而是GPS清洗、地图匹配、站序计算、ETA预测与缓存共同生成的结果。任何一层失真,都会让数字看起来合理却不可信。

原始数据来自车载GPS设备。系统先判断车辆位置是否可信,再把位置匹配到具体线路和方向,接着计算车辆沿线里程与剩余站点,最后结合时刻表、历史行程、实时速度和停站时间生成ETA。缓存决定用户拿到的是新结果还是旧快照,因此,公交 App 实时到站与 ETA 估算不能只从模型算法切入。

以下按典型线上排障的顺序梳理这条链路,不虚构具体公司事故或实测数据。

故障从数据链路开始

掘金热榜文章把公交实时到站背后的难题归纳为GPS漂移、消息乱序、ETA不准、10万QPS热点读、推送风暴和千亿级轨迹存储,并指出写入侧是高频小包流,查询侧是突发热点流,链路可以接受最终一致,但不适合追求全局强一致。

这个判断基本成立,但需要补一个前提:最终一致不能等于“旧数据没有标记”。车辆位置可以短暂滞后,页面却必须说明数据多久没有更新、来自实时链路还是时刻表。

典型故障往往按以下顺序扩散:

GPS点发生漂移,地图匹配选错线路,车辆沿线进度随之错误,剩余站数出现偏差,ETA模型又在错误路段上计算,最终缓存把错误结果快速分发给大量用户。

因此,看到“ETA不准”时,不应立刻替换模型。必须先确认车辆是否匹配到了正确线路、剩余站序是否正确、上报是否新鲜,再讨论预测误差。

容量和误差一起算

10万QPS只是一个流量目标,不能单独构成容量结论。至少要同时估算写流量、读流量、推送扇出、轨迹增量和延迟预算。

写QPS = 在线车辆数 ÷ 有效上报周期

读QPS = 峰值查询用户数 × 人均每秒查询次数

聚合更新QPS = 有效“线路+方向+站点”订阅组合数 × 状态变更频率

终端投递QPS = 有效连接数 × 每连接实际接收频率

轨迹日增量 = 在线车辆数 × 每车每日上报点数 × 单点平均字节数

推送侧把上游处理规模从“用户数”降低为“订阅组合数”,但最终向终端投递的消息量仍与连接数有关,两部分不能混为一谈。

针对10万QPS读流量,压测模型至少应定义目标QPS、持续时间、热点键比例、缓存命中率、逻辑过期刷新比例、对象大小和响应体分布。可以先按下式估算回源压力:

回源QPS ≈ 目标读QPS
         ×(1 - 本地缓存命中率)
         ×(1 - Redis命中率)
         + 逻辑过期产生的刷新QPS

误差则要拆开评估。剩余站数适合看错误站数比例和方向误判率;ETA除了平均绝对误差,还要看P90、分线路误差、峰谷时段误差以及置信度校准。平均误差很低,不代表少数线路不会持续给出糟糕结果。

容量和准确性必须一起验收:公交实时到站系统即使单次QPS未超标,仍存在数据大面积错误的问题。这也是它有别于普通信息查询系统的地方。

漂移先在入口处理

原始GPS点不能直接送进地图匹配。隧道、高楼遮挡、多路径反射和设备自身误差都可能产生漂移;同一辆车连续上报的坐标也可能突然跨越整条道路。入口层至少需要执行一系列检查。首先应检查时间戳,拦截未来时间、过早时间、时钟回拨和长时间未上报,并始终使用设备采集时间排序,而不是网络到达时间。其次需要验证速度与加速度,根据车型、路况和历史轨迹设置合理范围,排除瞬时跳变。

重复消息需要按车辆、设备会话和序号幂等处理,不能仅凭坐标相同就删除,因为车辆在站点停靠时本来就可能持续上报相同位置。最后需要检查连续性,依据时间差计算最大合理位移,拦截长距离跳点;若车辆已绑定运营线路,还可增加线路走廊和活动区域约束。

相关公交GPS研究将线性卡尔曼滤波及预测用于连续噪声处理,以改善后续地图匹配的准确性。 这类方法适合平滑随机误差,但不能替代规则校验。明显越界、速度异常和时间错乱的数据如果先进入滤波器,反而可能把后续轨迹一起带偏。

比较稳妥的做法是保留原始轨迹,另生成清洗结果。规则负责剔除明显坏点,滤波负责平滑连续噪声,地图匹配负责处理道路拓扑,两者职责不能混在一起。

乱序不能覆盖新状态

网络到达顺序不代表车辆真实运行顺序。同一辆车可能先上报较新的位置,随后才到达一条旧消息。如果服务端按到达顺序直接覆盖状态,地图上的车辆就会倒退,沿线里程和ETA也随之跳变。

每条上报至少需要携带vehicleId、设备会话标识、序号、采集时间、经纬度、速度、航向和定位质量。服务端以采集时间作为主要版本,以序号处理同一时间戳和重复消息;设备重启导致序号归零时,还需要设备会话标识辅助判断。

热帖提出的“时间戳版本号加Lua乐观锁”是合适方向:比较版本、写入新状态、更新车辆索引和设置TTL可以放在一个原子操作中,迟到消息直接丢弃,失联车辆则由状态TTL自动清理。

下面是核心逻辑示意:

-- KEYS[1]:车辆状态 Hash
-- KEYS[2]:旧线路进度 ZSET
-- KEYS[3]:新线路进度 ZSET
-- ARGV[1] capturedAt
-- ARGV[2] seq
-- ARGV[3] lat
-- ARGV[4] lon
-- ARGV[5] speed
-- ARGV[6] heading
-- ARGV[7] routeId
-- ARGV[8] direction
-- ARGV[9] distFromStart
-- ARGV[10] vehicleId
-- ARGV[11] ttlMs
-- ARGV[12] serverNowMs

local currentTs = redis.call('HGET', KEYS[1], 'capturedAt')
local currentSeq = tonumber(redis.call('HGET', KEYS[1], 'seq') or '-1')
local ts = tonumber(ARGV[1])
local seq = tonumber(ARGV[2])

if currentTs then
    local oldTs = tonumber(currentTs)
    if ts < oldTs or (ts == oldTs and seq <= currentSeq) then
        return 0
    end
end

redis.call('HSET', KEYS[1],
    'capturedAt', ARGV[1],
    'seq', ARGV[2],
    'lat', ARGV[3],
    'lon', ARGV[4],
    'speed', ARGV[5],
    'heading', ARGV[6],
    'routeId', ARGV[7],
    'direction', ARGV[8],
    'distFromStart', ARGV[9],
    'updatedAt', ARGV[12]
)
redis.call('PEXPIRE', KEYS[1], ARGV[11])

redis.call('ZREM', KEYS[2], ARGV[10])
redis.call('ZADD', KEYS[3], ARGV[9], ARGV[10])
redis.call('PEXPIRE', KEYS[3], ARGV[11])

return 1

这段代码只表达版本比较和原子更新思路。Redis Cluster下还需要处理跨槽Key问题,不能为了原子性把大量车辆状态和线路热点数据强行放进同一个槽。更合理的做法往往是车辆状态按车辆分片,线路进度独立更新,并接受两者之间极短窗口内的最终一致。

点先匹配到线路

公交查询的核心是判断车辆位于哪条线路、朝哪个方向行驶,以及已经沿线路走了多远。Redis GEO的半径查询只能找到附近车辆,无法处理双向线路、支线、环线和相邻道路。

地图匹配应先按线路、方向、运营时段和路网拓扑筛选候选路段,再计算投影位置和综合得分。示意逻辑如下:

for candidate in spatialIndex.query(point):
    projection = projectToPolyline(candidate.routeGeometry, point)

    spatialLoss =
        perpendicularDistance(point, projection) / gpsAccuracy

    headingLoss =
        angularDifference(point.heading, projection.direction)

    transitionLoss =
        distanceAndTimeCost(previousMatch, candidate, elapsedTime)

    score =
        w1 * spatialLoss +
        w2 * headingLoss +
        w3 * transitionLoss

choose candidate with minimum score

权重不能脱离当地数据固定。GPS精度较好时,空间距离可以占更高权重;高架、隧道出口或转弯路段,航向和前后点连续性更重要。评分结果还应保留置信度,不能只输出一个看似确定的路线编号。

几类特殊线路需要专门处理。双向线路不能仅按距离选择方向,应结合航向、站序和历史轨迹。支线共线段需要利用拓扑连续性和换乘节点进行区分。

对于环线,里程必须附带圈次或环行方向,不能无限累计后直接比较站点距离。夜间线路需绑定服务日期和线路版本,避免白天线路配置影响夜班数据。回库、场站和提前出场车辆若没有有效运营任务,应被排除在实时班次之外。此外,站点邻近道路的干扰十分常见,系统应先匹配车辆行驶的道路,不能直接就近吸附到站点。

地图匹配的输出至少应包含线路、方向、匹配路段、投影点、沿线里程、匹配得分和线路版本。后续站序与ETA都依赖这些字段。

剩余站数沿线路算

沿线路距离模型可以表示为:

车辆沿线位置:d
站点沿线位置:s1, s2, s3, ...,且 s1 < s2 < s3

下一站:第一个满足 si > d 的站点
剩余站数:按产品定义统计后续站点数量

“还有2站”必须先统一口径:是包含下一站,还是车辆经过下一站之后还剩两站。不同App的口径不同,但同一产品内部不能混用。

车辆状态按线路和方向组织,ZSET的score使用沿线距离。热帖将其概括为:利用一次区间查询捞出尚未过站车辆,已过站车辆可以自然过滤。

bus:state:{vehicleId}
bus:progress:{routeId}:{direction}:{routeVersion}

bus:eta:{routeId}:{direction}:{stationId}:{dataVersion}

对应在线数据模型:

数据 结构 主要内容 作用
车辆最新状态 Hash 采集时间、位置、速度、航向、线路、方向、里程 判断最新可信位置
线路车辆进度 ZSET vehicleId -> distFromStart 查找线路上的前后车辆
站点投影 静态路网或关系库 站序、沿线里程、线路版本 计算剩余站数
ETA快照 String或Hash ETA、更新时间、来源、置信度 承接热点查询
时刻表 关系库或离线结果 计划到站与班次信息 实时链路降级兜底

进出站判定还需要迟滞。热帖给出的示例是进入站点40米范围后标记到站,离开80米范围后再清除状态,以避免车辆在站台边缘反复横跳。 这组阈值只能作为起点,实际应结合GPS精度、道路宽度、车辆速度和当地站点环境重新标定。

低速或停站状态也要单独识别。车辆在红灯、站点和拥堵路段都可能接近静止,不能仅凭速度为零判断是否已经离站。否则,页面会在车辆尚未真正驶离时提前增加剩余站数。

ETA先有基线

ETA估算必须先建立可解释的基线,再叠加实时修正。无基线直接用当前速度乘剩余距离,会忽略班次差异、路口红灯、站点停靠和车辆实际运营状态。

一个通用结构是:

基础时间 =
    剩余路段历史或计划行驶时间之和
  + 剩余站点预期停站时间之和

实时修正 =
    近期速度偏差
  + 拥堵或异常事件偏差
  + 班次晚点偏差

ETA原始值 = 基础时间 + 受限幅约束的实时修正
ETA展示值 = 平滑后的ETA原始值

历史基线应按线路、方向、站间路段和工作日或周末、峰谷时段划分。停站时间也不应使用一个全局常数,大站与小区、上下班高峰与平峰的差异都很明显。

近期速度偏差可以将车辆最近经过的若干路段与历史基线比较,但要限制单次修正幅度。否则,一个异常GPS点或一次临时拥堵就可能让ETA跳变几十秒。展示层再做适度平滑,并保留数据更新时间。

热帖以分段基线、三层加权和卡尔曼增量修正,将某组样本的误差从180秒降至28秒。 这是特定数据和实现下的示例结果,不能当作公交ETA的通用指标。真正可复用的部分,是“基线加增量、修正要限幅、结果要平滑”的方法。

ETA还应输出置信度。输入数据过旧、地图匹配得分低、近期有效样本不足或历史基线稀疏时,置信度都应下降。一个没有置信度的精确数字,比带范围和来源的估计更容易误导用户。

路况模型渐进增强

高德地图的驾车ETA升级已经引入图卷积神经网络和iTransformer,用于解析实时路况并预判未来变化,同时纳入节假日、天气等因素。 这可以作为动态模型演进的横向参照,但不能直接套用到公交。

公交与驾车运行环境不同。公交需要经过固定站点并承担上下客时间,运行还受到公交专用道、路口优先级和车辆班次约束的影响。站点附近的高峰聚集、临时停靠,以及同一路段上不同线路之间的差异,都是驾车模型未涵盖的变量。

因此,更稳妥的演进顺序是先完成可解释的时刻表与历史基线,再将机器学习用于预测基线残差。新模型应先以影子模式运行,在不直接影响用户的前提下与基线进行比较。经过按线路和时段验证后,再通过小流量灰度逐步上线,并且必须保留基线回退路径。

动态模型用于提高平均精度,确定性基线负责兜底和解释,后者不能被前者取代。

热点查询逐层降载

查询路径应按Caffeine本地缓存、Redis共享缓存、ETA服务、实时状态存储和时刻表回退的顺序逐层下探。热帖提出的多级缓存、SingleFlight、逻辑过期和TTL随机抖动组合,适合处理高并发热点读。

不同缓存层承担不同职责。本地缓存用于吸收同进程内的高频请求并降低网络开销。Redis用于跨实例共享结果。SingleFlight负责合并同键回源,避免缓存同时失效时重复计算。逻辑过期允许在物理TTL结束后短时间返回旧值并触发刷新。TTL随机抖动则用于打散集中失效时间。

SingleFlight可以按下面的方式组织:

func GetETA(key string) Result {
    if value := localCache.GetFresh(key); value.Found {
        return value
    }

    if value := redis.Get(key); value.Found {
        localCache.Set(key, value)
        return value
    }

    if value := localCache.GetStale(key); value.Found {
        refreshGroup.Do(key, func() {
            return refresh(key)
        })
        return value.WithStaleFlag()
    }

    value, _ := singleflight.Do(key, func() {
        if cached := redis.Get(key); cached.Found {
            return cached
        }

        result := etaService.Compute(key)
        redis.SetWithJitter(key, result)
        localCache.Set(key, result)
        return result
    })

    return value
}

实际实现还要处理刷新失败、调用方取消和超时。刷新失败时应继续提供带陈旧标识的旧值,而不是让请求一起失败。

缓存键应包含线路、方向、站点和数据版本。线路调整或时刻表更新时,可以通过版本自然隔离旧数据。失效通知只覆盖受影响的线路与站点,不能因为一辆车更新就清空整座城市的缓存。

10万QPS最终有多少请求真正落到ETA服务,取决于本地缓存和Redis的实际命中率。若回源比例仍然过高,应优先把ETA计算改成写入侧预计算或增量更新,而不是继续堆叠查询锁。

推送先聚合再扇出

推送侧应按“线路加站点”聚合订阅。热帖将这种设计概括为用订阅组合数替代用户数作为主要扇出规模。 这能减少上游重复计算,但并不会消除终端连接本身的投递成本。

一次状态更新通常只需要更新对应线路和站点的ETA快照,随后由推送服务将快照分发给订阅该组合的连接。为了进一步控制推送规模,还应合并短时间窗口内的重复更新,并对单一组合设置更新频率上限。在异常流量暴增时,应主动降级为低频摘要推送。

推送队列需要具备背压和限流能力,慢消费者应被隔离,不能拖住其他线路。针对断线重连场景,系统只需发送最新版本,避免重放全部历史事件。查询链路与推送链路也应使用独立资源池,确保推送风暴不会反向拖垮实时查询。

对用户而言,“还有2站”通常不需要每移动几米就收到一次通知。按站点聚合和短窗口合并更符合实际需求。

历史轨迹冷热分层

车辆最新位置、站序和ETA属于短生命周期的在线数据;原始轨迹、清洗轨迹和模型特征属于历史数据。两者如果共用同一套索引和存储策略,成本与查询压力会被同时放大。

合理做法是把数据分成三层。原始轨迹层完整保留设备上报,主要用于审计、故障回放和重新清洗。在线状态层只保留每辆车的最新位置、线路进度和状态版本,并通过TTL处理失联车辆。特征与历史层则保存匹配后的里程、速度、停站时长和班次标签,供离线回放、模型训练和线路分析使用。

热帖将千亿级轨迹列为必须正视的存储挑战。 长期保留时,应按日期、城市、线路或车辆分区,压缩原始数据,并把低频访问数据迁移到成本更低的存储。实时查询不应扫描原始轨迹。

原始数据是否全量保留,需要结合合规要求、故障复盘周期和存储成本决定。不能因为历史数据“以后可能有用”就无限期保留所有重复点与无效点。

降级先标注来源

实时到站系统的降级链应当是:

实时ETA
→ 最后可信位置加历史速度
→ 时刻表估计
→ 明确显示暂无实时数据

热帖提出的原则是,系统可以不准,但不能不可用;宁可显示“约5分钟(时刻表)”,也不要让页面空白。 但降级结果必须明确来源,否则用户会把估算值误认为实时定位。

接口可以同时返回:

{
  "etaSeconds": 300,
  "source": "timetable",
  "stale": true,
  "confidence": "low",
  "positionUpdatedAt": null,
  "generatedAt": 1710000000000
}

Redis故障时,本地缓存尚可提供带陈旧标识的结果;本地缓存也失效时,回退到实时状态存储或时刻表。GPS断流时,车辆状态到期后应退出实时候选,不能把最后一个坐标无限外推。实时数据过期与缓存大面积失效必须分别处理,前者需要改变数据来源,后者则重点保障已有结果的可读性。

回放测试覆盖全链路

上线前应使用真实车辆轨迹回放整条链路。正常回放保留原始采集时间,同时注入网络延迟、乱序、重复消息和断流;异常场景则覆盖GPS失联、线路切换、站点匹配错误、热点键同时失效、Redis不可用和推送队列积压。

测试不能只看平均响应时间,至少需要建立以下指标:

环节 核心指标
GPS清洗 坏点拦截率、误杀率、连续性异常数量
地图匹配 匹配成功率、错线路率、错方向率
站序计算 错误站数、到站状态误判率、反复横跳次数
ETA MAE、P90、分线路和分时段误差、置信度校准
数据新鲜度 位置更新时间、ETA生成时间的P95和P99
查询链路 命中率、回源QPS、P99、错误率、降级比例
推送链路 端到端延迟、合并比例、慢消费者数量
故障恢复 状态过期速度、恢复后积压清理时间

10万QPS压测还应使用真实的线路和站点热度分布,加入缓存集中失效、Redis切换和回源放大。只用均匀随机键压测,通常会低估真实热点问题。

模型无法补齐失联数据

公交ETA仍然会受到GPS失联、路网与时刻表元数据质量、突发拥堵、车辆停站时长和上下客数量影响。设备离线时,任何模型都无法恢复不可见的事实;元数据错误时,再复杂的算法也只是在错误基线上计算。

因此,公交实时到站系统更可信的目标,是每次都提供可解释的数据来源、明确的新鲜度和可度量的误差区间,避免作出结果始终精确到分钟的虚假承诺。实时链路失效时,系统应明确退化到时刻表,而不是继续展示一个无法证明仍然新鲜的数字。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容