ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

5万骑手每5秒上报一次位置,系统会不会写死?架构实战拆解

5万骑手每5秒上报一次位置,系统会不会写死?架构实战拆解 “5万骑手每5秒上报一次坐标系统会被写死吗”这个问题我经常被问到。说白了这是一道非常典型的高频位置上报架构题外卖、快递、网约车、货运调度都会遇到。先别急着下结论说MySQL扛不住我们得先把流量账算清楚5万骑手每5秒上报一次平均QPS就是10000如果早午晚高峰再叠加一下冲到2-3万QPS也不意外。再加上重新登录补传、断网重传、多端在线实际写入量只会更高。这篇文章我就用实际跑过的设计思路把整个链路拆开怎么接住这个流量哪些环节会让系统真正“写死”以及怎么避开那些文档里不写的坑。先给我的结论5万骑手不会把系统写死但如果你让坐标直接“同步写库”且没有任何缓冲和削峰手段被写死是大概率事件。真正被写死的不只是数据库还可能是连接池、磁盘IO、GC甚至是日志输出。下面逐步拆。1. 先算账5万骑手 × 5秒到底多大流量1.1 先从QPS和存储算起先算一笔最简单的数学账。平均QPS50000 ÷ 5 10000也就是说系统每秒至少收到1万条位置上报。峰值QPS骑手并不是均匀上报。每次接单、到达、送达等关键节点终端往往会临时缩短上报间隔甚至降到2秒一次午晚高峰瞬时QPS做到均值3倍并不夸张也就是2-3万QPS。单骑手每天上报次数86400 ÷ 5 17280次。全天上报总条数17280 × 50000 8.64亿条。这8.64亿条并不是“存不住”而是要思考怎么存才划算。假如一条完整位置数据约200字节骑手ID、经度、纬度、速度、方向、上报时间、业务状态裸数据一天就是172GB左右存90天就有15TB以上。这还没算索引、副本和压缩前的冗余。很多人一上来就想1万QPSPostgreSQL/MySQL单机也能扛吧单看插入1万QPS确实有一些数据库能硬扛。但问题在于数据量8.64亿条/天的积累会迅速让索引变大、让脏页刷盘变频繁到了第三天、第十天性能就断崖式下跌。所以不能说“数据库能不能扛住1万QPS”而是“这个写入模型能扛多久”。存储容量、查询延迟、运维成本全是连锁反应。1.2 “写死”不只是数据库被打满大家常说的“写死”在真实系统里有好几种死法数据库连接池被取空。如果代码是同步Insert1万QPS进来连接池只有50其他请求全在排队等连接接口超时后客户端重试重试又把流量放大了最后连接池彻底瘫痪。InnoDB写入路径瓶颈。大量随机小写入让B树索引页不断分裂缓冲池脏页来不及刷盘日志缓冲压力陡增最终IO被打满。JVM GC停顿。如果服务端为每个请求创建一堆短生命周期对象年轻代回收频繁GC停顿会让RT飙升。日志同步刷盘。很多服务死得最冤因为业务方习惯在每个请求里打日志用的还是同步Appender磁盘写入一旦抖动整个应用阻塞。我实测过一个很小的调度后台骑手只有2000人上报每秒也就400条结果接口平均RT到了200ms。定位下来不是数据库问题而是打印日志太夸张。改成异步日志后RT直接掉到20ms。所以“写死”这件事先别急着甩锅给数据库。2. 别把坐标直接插库整体链路怎么设计2.1 核心思路异步化批量换在线如果让“客户端上报 - 应用层处理 - 直接INSERT数据库”一条同步链路走完那每一秒1万次写入就是1万次数据库小事务、1万次磁盘随机写。正确思路是反过来的先让请求快速落地再异步批量涌入存储。具体说位置上报接口要做的事情尽量少。收到坐标后只做三件事基本参数校验。把最新坐标写到缓存保证“当前骑手在哪”这种高频查询不读数据库。把这条坐标事件投递到消息队列立即返回成功。真正的落库操作交给消费端消费端攒一批数据后再批量写入存储。这样应用层面对的是10000 QPS的请求但存储层面对的可能只是每秒十几次批量写入。削峰填谷之后数据库压力完全可以降下来。2.2 接入层代码骨架Redis更新位置MQ做缓冲我习惯用“Redis Kafka 批量写库”这套组合很多定位系统都够用。上报接口的Java骨架大概是这样的RestController public class LocationController { PostMapping(/location/report) public Result report(RequestBody RiderLocation location) { // 1. 基础校验骑手ID、经纬度范围、上报时间 if (location.getRiderId() null || location.getLat() -90 || location.getLat() 90 || location.getLng() -180 || location.getLng() 180) { return Result.paramError(); } // 2. 最新位置写入Redis保留短时间即可例如5分钟 String locKey rider:loc: location.getRiderId(); redisUtil.hSet(locKey, lng, String.valueOf(location.getLng())); redisUtil.hSet(locKey, lat, String.valueOf(location.getLat())); redisUtil.expire(locKey, 300, TimeUnit.SECONDS); // 3. 投递MQ按riderId做key保证同一个骑手的轨迹有序 LocationEvent event LocationEvent.from(location); kafkaTemplate.send(rider-location, location.getRiderId().toString(), event); return Result.ok(); } }这里有几个现场才懂的小细节Redis和MQ哪个先写我的做法是先写Redis最新位置再发MQ。因为即使MQ挂了骑手的实时位置还有Redis兜底轨迹稍微缺一点可以后续补偿。如果先发MQ成功再写Redis失败问题更麻烦。不要每次上报都调外部坐标转换服务。坐标转换等重逻辑放到消费端批量做否则10000 QPS打到外部服务上别人不封你才怪。Redis写入失败时不能直接吞掉。至少要有降级策略记录到本地日志或临时队列等恢复后补偿。如果有团队不愿意一开始就上Kafka也可以先用本地内存队列攒批。核心原则是一样的把高频小请求变成低频大请求。比如用一个带缓冲的List攒够200条或者每200ms flush一次。// 简化示意生产者丢进队列消费者定时批量取出 ArrayBlockingQueueLocationEvent buffer new ArrayBlockingQueue(10000);但要提醒单体内存队列在单机部署时没问题一旦扩容到多实例每条记录可能落在不同机器上批量效果反而不好。所以流量真到了1万QPS直接上MQ是更稳的选择。2.3 消息队列的选型和分区设置Kafka是这类场景最常见的选项。为什么它能扛住本质上是因为Kafka把写入变成了顺序追加配合页缓存和零拷贝单分区轻松跑到每秒几万条。5万骑手这个量级根本不用担心它的“写入能力”真正要关注的是分区设计和消费顺序。一个骑手在不同时间上报的点最好由同一个消费者线程处理否则两条记录先后顺序可能颠倒。所以发送消息时key建议用riderId让同一骑手的消息都进同一个分区。分区数不能太小。如果分区数只有8个消费者最多只能开8个处理不过来就会积压。建议一开始就按峰值QPS和消费吞吐预留容量比如24或48个分区。acks参数可以设置成1在定位场景下允许极端情况下少量丢失换取更低延迟。如果严格要求不丢再考虑acksall但性能会下降。消费端一定要做幂等和乱序处理。Kafka的at-least-once语义意味着消费者重启后可能重复消费写库时需要去重。3. 坐标存储怎么存才不变成“数据黑洞”3.1 热数据、冷数据分开对待不是每一条坐标都需要永久存下来。我们需要先想清楚业务到底要什么**“骑手现在在哪儿”**属于热查询需要最新位置数据量很小适合放Redis。**“某个骑手从几点到几点的轨迹”**是低频查询属于历史轨迹才需要落到数据库或文件存储。**“某区域有多少骑手”**是空间查询适合用GeoHash或S2做二级索引。所以存储设计的第一条原则是不要把80%的查询都压在一张放了几十亿行的大表上。我见过团队把5秒一个点的轨迹全量留90天结果业务方根本不会看那么细。后来和产品商量正常行驶时服务端只保留每15秒一个点关键节点接单、送达、异常停留才保留5秒甚至更高频。这样存储量一下降到原来的三分之一查询体验也没明显变化。3.2 存之前先过“坐标规范”这一关坐标类的坑远比想象中多。相关搜索词里有一堆坐标转换、天地图坐标拾取、WGS1984和CGCS2000坐标对不上其实都指向同一个问题坐标系不统一数据就是垃圾。我见过最典型的现场终端上报的是手机GPS原始坐标WGS84服务端拿到的却是高德/腾讯地图纠偏后的坐标GCJ02两者差几十米到几百米。如果不做标记直接混着存到了晚上骑手轨迹看起来像在河里漂。所以在数据进入MQ之前上报协议里必须带坐标系类型例如{ riderId: 10086, lng: 116.397, lat: 39.908, coordinateType: wgs84, source: gps, accuracy: 12, reportTime: 1710000000000 }服务端在消费时统一做三件事把坐标系归一化。国内业务通常统一到GCJ02或者按地图服务商的规范统一做地理计算时用CGCS2000/WGS84。校验经纬度是否在合理范围内精度值过大比如超过100米的点降权或直接丢弃。计算GeoHash编码。GeoHash可以把二维坐标转成一维字符串用前缀匹配做“附近查询”。比如8位GeoHash大约覆盖38米×19米已经能满足大部分骑手分布查询。至于从陀螺仪/加速度计推算XYZ坐标、触摸屏点坐标映射到屏幕内容这些都是终端侧的处理逻辑服务端不关心。上报到后端的只应该是“最终经纬度 坐标类型 精度”不要传原始传感器数据否则又增加解析和传输成本。3.3 关系库分表、批量入库与时序库的选择如果你已经有了MySQL且日活规模不大可以先不上时序库直接用分表方案起步。关键点按rider_id哈希分表或按月份分表。定位场景建议两者结合先按月分表再按骑手ID打成多张分表。主键不要用无意义自增ID。高并发下全局自增锁会是热点而且轨迹查询经常按骑手时间范围查。用rider_id report_time作为联合主键更合适。经纬度不要存DECIMAL然后每次字符串比较可以放大到整数存比如lng (int)(经度 * 1e6)减少存储空间和比较成本。批量插入用INSERT INTO ... VALUES (...), (...), (...)JDBC驱动一定要配置rewriteBatchedStatementstrue否则你以为的批量实际上还是一条条发。一个参考表结构CREATE TABLE rider_location_202407 ( rider_id BIGINT NOT NULL, order_id BIGINT NOT NULL DEFAULT 0, report_time BIGINT NOT NULL, lng INT NOT NULL, lat INT NOT NULL, geohash CHAR(8) NOT NULL DEFAULT , speed SMALLINT NOT NULL DEFAULT 0, direction SMALLINT NOT NULL DEFAULT 0, source TINYINT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (rider_id, report_time), KEY idx_order_time (order_id, report_time) ) ENGINEInnoDB;如果团队能接受引入更多组件也可以直接用时序数据库比如TDengine、TimescaleDB。时序库天生就是按设备和时间线组织数据批量写入和范围查询都比MySQL高效。但要权衡运维成本时序库在轨线回放场景比MySQL舒服很多前提是你愿意多维护一套系统。从成本角度我给过一个比较务实的路径初期用“Redis Kafka MySQL分表”量上来以后再往时序库迁移。不要一上来就追求最牛架构先保证系统能活过双11。4. 查询侧也不能给系统“补刀”4.1 “附近骑手”查询用Redis GEO别去SQL里扫位置上报是写高频但“查附近骑手”才是用户感知最强的读场景。顾客打开App想看周围3公里有没有骑手可以顺路带东西如果每次都查MySQL的经纬度范围数据库会被拖垮。Redis自带的GEO命令非常适合这个场景GEOADD loc:hangzhou 120.15 30.28 10086GEORADIUS loc:hangzhou 120.153 30.282 5 km WITHCOORD WITHDIST COUNT 20 ASC这样能直接返回5公里内最近的20个骑手性能是毫秒级。注意不要把所有骑手都塞进一个全局Key否则热点严重查询也不准确。按城市或区域拆分key写入时先根据坐标判断区域再写入对应的key。离线的骑手要定期从GEO集合里移除否则用户会看到“幽灵骑手”一直停在原地。一个常规做法是在消费端持续跟踪骑手最近上报时间超过阈值就发一条下线事件把骑手从GEO集合里删除。4.2 轨迹回放要异步化、静态化历史轨迹查询是另一个很容易被打挂的接口。用户看一个骑手的轨迹一次能拉几百个点如果每个点都实时查库一个用户就能产生几百次查询。更合理的做法轨迹点按订单或骑行会话聚合跑一个异步任务把点序列生成JSON或ProtoBuf文件。文件放到CDN或对象存储客户端直接拉文件服务端不再参与实时轨迹计算。如果一定要做实时位置上报最多把最近一两分钟的轨迹点放Redis用RPUSH存一个临时列表过期自动清除。用“读静态文件”替代“查实时库”是把读压力从核心链路摘出去最有效的方法。很多系统不是被写死而是被“看历史轨迹”的用户查死的。5. 那些真正让我踩过的坑5.1 漂移点、惯性导航坐标服务端要敢丢GPS在城市峡谷里漂移是常态。系统里如果傻乎乎把每一个点都存下来会得到一堆锯齿状轨迹。服务端至少要做一层速度校验计算前后两个点的球面距离除以时间差如果超过合理速度比如200km/h直接丢弃如果超过正常速度但这个骑手确实在高速上就降低存储优先级。计算距离可以用Haversine公式不要用经纬度直接算欧式距离尤其是高纬度地区误差很大。还有一些终端会传“陀螺仪推算坐标”这类坐标在没有GPS信号时很常见但误差会随时间累积。服务端不能把推算坐标当作GPS一样信任必须看source字段。质量太差的点要么不存要么只用于实时展示不能进历史轨迹。5.2 乱序、重复上报幂等必须做终端网络不稳定消息可能在消费者重启后重复消费也可能在网络层乱序到达。如果消费端只是简单覆盖写入会出现旧点覆盖新点、重复点堆满表的问题。处理原则很简单按report_time比较只保留时间戳更大的点旧点丢弃。幂等键使用rider_id report_time重复消息直接忽略。如果在MySQL里可以给rider_id report_time加唯一索引重复插入报错后忽略如果在其他存储里消费端自己维护一个去重窗口。这条不能省否则线上数据质量会越来越差。5.3 压测的时候先检查日志和连接池我压测过一个模拟设备集群5000个设备每秒上报3条也就是1.5万QPS。一开始怎么压都上不去CPU大量消耗数据库却闲得很。最后发现是logback同步输出日志每条消息起码打印两行日志磁盘和锁全卡住了。改成异步Appender后同样配置下吞吐直接翻倍。所以做压测时要同时看四类指标应用CPU、GC频率数据库CPU、磁盘IO线程池/数据库连接池活跃数和等待数日志写入量如果连接池线程大量BLOCKED先看锁和连接池配置而不是加机器。如果数据库CPU很低但应用CPU很高先怀疑序列化和日志。5.4 区域热点分区和消费者都要留余量外卖平台一定存在热区某个商场美食城附近扎堆几百个骑手消息全部涌向同一个Kafka分区这个分区会积压其他分区却很闲。解决思路发送消息的key不要只用riderId可以加上区域因子比如cityId riderId让同一个区域的骑手分散到多个分区。消费者数量不要死等于分区数要留一部分弹性或者用Kafka Streams做按区域聚合。压测时重点模拟“单点热点”不只是均匀QPS。我见过很多系统压测均匀流量全通过一到真实运营就被一个商圈打爆。6. 常见问题速查和我的部署建议6.1 高频问题对照表现象可能原因解决思路上报接口大面积超时同步写库导致连接池耗尽改为异步MQ 批量写库数据库CPU不高应用CPU飙高日志同步输出改异步日志降低无谓日志量骑手位置像雪花一样飘坐标系未统一 / 漂移点未过滤上报带坐标系类型消费端做速度校验轨迹回放出现时间倒挂消息乱序比较report_time旧点丢弃某个商圈附近积压分区路由不均匀路由key加入区域维度扩展分区存储增长太快全量保留每个5秒点采样保留关键节点才加密附近骑手查询很慢直接SQL范围扫描改用Redis GEO或GeoHash索引6.2 我的部署建议与个人体会这套链路我真正跑起来之后的体会是先想清楚哪些数据必须“实时”哪些数据可以“准实时”再下手设计。骑手当前位置必须实时用Redis就够了历史轨迹不需要实时走MQ 批量写库完全没问题统计报表更不需要实时定时任务聚合即可。如果团队比较小我的建议是先只做两条线上报接口接Redis写MQ。消费端批量写MySQL分表。等量真的上来再引入时序库或加缓存集群往迁移走也会很顺。千万不要一开始就把HBase、Flink、Kafka Streams全都堆上否则维护成本会先把团队拖垮。还有一个细节上报接口响应一定要快。终端设置的是每5秒上报一次如果服务端响应超过1秒终端就会不断重试反而把压力放大。宁可让接口在100毫秒内返回哪怕这条数据最后丢了也不要让终端无限重试拖垮整个链路。服务端要敢丢、敢降级业务才能稳。5万骑手每5秒上报一次在架构合理的前提下完全不会被写死。真正写死系统的永远是那些没做缓冲、没做隔离、没做取舍的实现方式。
返回列表