ARTICLE DETAIL

资讯详情

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

车联网数据平台分布式缓存选型与事故治理实战

车联网数据平台分布式缓存选型与事故治理实战 周末晚上十点我盯着监控大屏上的在线率曲线从99.2%慢慢滑到94%。当时平台已经接入了三十多万辆智能网联车集中在上线高峰期遥测数据的写入和查询同时猛增数据库的连接池先扛不住了。排查到最后根子不在某一条SQL而在车联网数据平台本身的读写模型大量高频写入、大量的重复查询再加上早晚高峰的尖峰流量把存储层直接打穿。那天晚上我做的第一件事就是决定在应用和数据库之间加一层分布式缓存先把重复读拦住给数据库留出喘息空间。如果你也在做车联网相关平台或者正在处理类似“设备多、写入快、查询杂”的物联网数据场景这篇文章值得看完。我会把分布式缓存的选型思路、缓存模型设计、踩过的三个真实事故、容量评估方法和压测数据一次讲透全部基于实际跑过的方案不是教科书式的概念罗列。后面涉及的具体数据和配置都以我们当时的环境为准但思路可以平移到你自己的场景里。1. 车联网数据平台的吞吐画像为什么缓存成了刚需1.1 遥测、档案、指令三类数据的读写特征完全不同很多团队在给车联网平台做架构时第一反应是“提高数据库性能”却忽略了进入平台的数据天然分成了三个截然不同的类别每一类对存储和缓存的要求都不一样。第一类是车辆遥测快照数据。智能终端T-Box以1秒到30秒不等的频率上报位置、速度、方向、电池SOC、电机扭矩、车门状态等字段。这类数据的特点是写入极高频、单条非常小、但读取时往往只关心“最新状态”。比如监控大屏展示某辆车的当前位置只要最新的那一条就够了。第二类是车辆与用户档案数据。包含车型信息、车牌号、车主手机号、套餐开通状态、设备序列号、保险到期时间等。这类数据几乎不频繁变化但会被反复查询。一次远程控制指令下发前平台要校验车辆档案、套餐状态、设备在线状态可能一次请求要读十几张表的信息。第三类是设备连接与指令状态数据。设备是否在线、指令是否下发成功、任务当前执行到哪一步这些数据被读得很频繁写也有一定频率而且要求尽量实时。分开看才能发现矛盾遥测数据是“写多读少”档案数据是“读多写少”但所有查询都是密集的小请求单位时间内的读QPS可以远远超过数据库的连接处理能力。1.2 读放大效应大屏、车队页面和运营后台的真实压力我们当时做过一次流量拆解发现读放大是平台最主要的存储压力来源。一个典型的车队监控页面打开后前端会一次性请求该车队下100辆车的实时状态大屏每1到2秒轮询一次全部在线车辆运营平台批量导出车辆信息时会按分页逐条查询档案。这些查询本质上都在反复读同一批数据。假设平台有30万辆在用车辆正常在线率是85%大屏每2秒刷新一次每次刷新需要读取25.5万辆车的状态。这还只是大屏的一个页面。加上车队管理、告警查询、主动安全监管等模块的轮询存储层每秒要承受的读请求量远超那25.5万的倍数。如果这些请求全部穿透到MySQL或时序库任何一个单机数据库都撑不住。更麻烦的是尖峰效应。上下班高峰、节假日前的大规模活动、远程OTA升级包下发都会造成某个时段内所有车辆同时上报、同时被查询。数据库连接的创建和释放是有代价的连接池打满之后连正常业务请求都会跟着被阻塞。1.3 为什么不是“先优化SQL”而是“加缓存”我见过不少团队先花大量时间做SQL优化、加索引、做读写分离效果当然有但到后期收益会非常有限。车联网平台的瓶颈从来不是单条查询慢而是单位时间内查询总数太多。一个查询从500ms优化到50ms如果每秒需要执行10万次依然需要5000个并发连接才能覆盖。分布式缓存的价值恰恰在于它把“每次都算一遍”变成了“算一次读很多次”。实时状态类数据写入缓存后查询端直接从缓存取数据库只承担批量落盘和离线计算的工作。这样数据库连接池的压力能下降几个数量级尖峰流量也能被削平一部分。我们要做的就是把这层缓存设计好既不能让它成为新的单点也不能因为缓存一致性引入新的脏读问题。2. 缓存方案选型的排雷记录Redis Cluster、Codis还是Pika2.1 先把需求边界列清楚再谈选型选型前我们列了一张极简需求表约束条件很现实数据规模状态类和档案类数据加起来预计100万辆车规模下不超过几十GB不需要无限容量。性能要求读写延迟P99控制在5ms以内日常平均1ms左右。一致性要求状态类数据允许秒级过期和短暂偏差档案类数据必须保证最终一致。运维成本团队只有两三个人兼顾平台和业务没法维护复杂的组件体系。基于这几条我们重点考察了三个方案Redis Cluster、Codis和Pika。下面表格是当时整理的对比结论。方案协议兼容水平扩展能力运维复杂度社区活跃度典型问题Redis Cluster原生Redis协议官方支持按哈希槽自动分片在线扩容缩容低官方工具齐全高迭代活跃多key操作受限需要客户端支持集群协议CodisRedis协议Proxy模式按key哈希分片支持平滑迁移高依赖ZooKeeper、Proxy、Dashboard等多个组件社区已基本停滞多一层Proxy增加延迟和故障点大key会卡住整个ProxyPika兼容Redis协议基于RocksDB容量远超内存中单实例为主中等纯内存热数据场景下延迟比Redis高高并发读能力受限2.2 最终选择Redis Cluster的三个核心理由我们的结论是用Redis Cluster而且用官方集群模式不用Codis。理由有三第一车联网平台很多查询是单key或小范围key操作天然适合Redis Cluster的哈希槽模型。客户端根据CRC16计算槽位直接访问对应节点请求路径短没有额外代理层。Codis的Proxy虽然屏蔽了集群细节但每个请求都要经过Proxy转发QPS上去后Proxy自身容易成为瓶颈而且一旦Proxy挂了后面所有缓存请求全部中断。第二在线扩容能力对我们很重要。接入车辆数会随着业务增长持续上涨Redis Cluster可以用redis-cli --cluster reshard在线迁移槽位扩容时不需要停服务。Codis也支持迁移但需要维护Proxy和ZK的状态流程更重。第三社区活跃度意味着踩坑有解。这个不能忽视Redis Cluster遇到的客户端连接问题、slot迁移期间的访问抖动、内存碎片问题在社区都能找到成熟的解决方案或升级补丁。Codis已经很久没大版本更新了新业务上去心里不踏实。Pika我们其实也很喜欢它的RocksDB存储引擎让单机容量可以轻松超过内存非常适合把Redis用作“存储层”而不是“纯缓存层”。但我们的场景里真正高频读的热数据量不大内存完全装得下没必要引一个性能稍弱、需要单独理解和运维的组件进来。2.3 集群拓扑和基础参数照着抄也不会出大错我们用Redis 5.0.14搭建了三主三从的Cluster集群每个主节点8GB内存。三主分摊16384个哈希槽每个从节点作为对应主节点的实时副本保证单节点故障时客户端能自动切换。节点配置里需要注意几个关键参数maxmemory 6gb实际给节点设了6GB上限留出2GB给操作系统的page cache和Redis自身进程开销。不要顶格设满否则触发内存淘汰时容易整节点卡顿。maxmemory-policy volatile-lru只对设置了TTL的key做LRU淘汰保护那些不带过期时间的永久key比如车辆档案的底表。appendonly yes开了AOF刷盘策略为everysec。缓存丢了可以忍受但尽量别丢尤其是指令状态类数据丢了会影响远程控制结果的判断。cluster-enabled yes启用集群模式后节点启动时用redis-cli --cluster create完成握手和槽位分配。当时网上能找到的教程大多还停留在单机Redis或主从哨兵模式我们一开始也犹豫要不要用哨兵。后面想明白了哨兵解决的是“高可用”不解决“水平扩展”。车联网平台的缓存数据量虽然不大但QPS增长很猛今天三主够用明年可能就要六主甚至更多。Redis Cluster把分片和高可用一起解决了省去后续的大规模改造。3. 缓存分层与key设计放什么、不放什么、过期怎么定3.1 分层缓存模型L1实时状态、L2档案聚合、L3设备在线缓存不是一股脑把数据库所有表都塞进去我们按数据特征分了三个缓存层。L1是车辆实时状态缓存。K/V结构key是车辆VINvalue是遥测快照的Hash结构字段名对应GPS、速度、电量等遥测项。写入路径是接入网关解析MQTT报文后直接写缓存同时投递一条异步消息给Kafka由下游消费者负责落库到时序库。读取路径是大屏、车队页面、告警模块直接读缓存几乎不碰数据库。L2是车辆档案聚合缓存。把一次业务请求需要的档案字段车型、车主、套餐、设备SN等合并成一个聚合value。查询时一次GET全部拿出来避免一次操作查询多张表再拼接。档案数据低频更新所以这个缓存层我们用主动失效机制档案修改时更新数据库再通过消息队列通知缓存服务删除对应key。L3是设备在线状态缓存。每辆车上报任意遥测消息时都执行一次SETEX online:{vin} 30 1把在线标志的过期时间刷新为30秒。查询设备是否在线只需要GET这个key存在就算在线过期自动判定离线。这样做比数据库记录心跳精确很多也避免了频繁更新数据库心跳时间。全量轨迹数据、历史告警数据、T-Box原始报文这些都不进缓存。轨迹数据价值密度低、单车辆数据量极大缓存装不下也没有实时查询需求原始报文直接走消息管道落入对象存储和数仓缓存层不碰。3.2 key命名规范与哈希标签的使用key设计看似简单实际是后面所有排障的基础。我们的规范是三级制业务域:对象类型:维度。比如telemetry:{VIN}:latest—— 最新遥测快照profile:{VIN}:aggregate—— 车辆档案聚合online:{VIN}—— 在线状态标志cmd:seq:{指令ID}:status—— 指令执行状态这里有个技术细节Redis Cluster中多个key的哈希槽由CRC16(key)%16384决定如果key之间没有关系它们大概率落在不同节点上。但车联网场景经常需要同一辆车的多个key一起操作比如一次读取车辆状态时要同时查telemetry:{VIN}:latest和online:{VIN}。为了保证这些key落在同一个槽位我们在key里加上了哈希标签{VIN}:telemetry:latest{VIN}:profile:aggregate{VIN}:online花括号部分会单独参与哈希槽计算这样同一辆车的所有key必定落在同一个节点上查询时可以用Pipeline一次批量获取不会出现跨节点多次网络往返。这个设计对后续性能提升非常关键。3.3 TTL设置与“过期风暴”预防所有缓存key都设计了TTL没有设计成“永久缓存”。遥测快照的TTL设置为10秒到30秒之间随机分布档案聚合的TTL设置为一小时设备在线标志用滑动过期即每次写入刷新为30秒。这里的核心目的是防止雪崩。假设30万个遥测快照key全部设置30秒过期且在同一秒写入那么就会在同一秒全部过期下一瞬时大量请求同时穿透到数据库。我们在写入时给TTL加一个随机抖动import random ttl 10 random.randint(0, 20) # 10~30秒随机 redis.hset(f{{{vin}}}:telemetry:latest, mappingframes) redis.expire(f{{{vin}}}:telemetry:latest, ttl)这样同一个上报周期内创建的key过期时间会均匀分布在20秒区间内数据库承受的穿透压力被摊平了。另外maxmemory-policy volatile-lru保证了内存压力大时只会淘汰可过期key不会误删档案底表这类不想丢的数据。3.4 缓存更新链路遥测快照“缓存先行”档案数据“双删失效”车联网实时状态数据的并发更新极其频繁如果按照“先更新数据库再删除缓存”的经典方案做会产生一个竞态窗口数据库更新还没完成时另一个线程读到旧值并写回缓存把新值覆盖了。所以我们反过来把缓存当作实时状态的“事实来源”先写Redis再异步落库。下游落库即使慢一点读取端拿到的始终是最新状态。档案类数据的更新频率低但一致性要求高我们用的是“双删”变体业务操作先更新MySQL发送一条缓存失效消息给MQ缓存消费者收到消息后删除对应key为了保证极端并发下的最终一致延迟5秒后再删除一次。第二次删除是为了兜住一个窗口期第一次删掉缓存后一个并发读请求恰好在更新完成前把旧档案重新写回了缓存。5秒延迟删除保证了旧值最终不会长期残留。这套链路跑了一段时间后我们意识到一个关键问题缓存层不仅是“挡在数据库前面的盾”它自己也得有可靠性和降级预案。于是有了后面要讲的几个事故教训。4. 三个差点打垮平台的缓存事故与完整排查链路4.1 缓存穿透不存在的VIN把数据库打成了慢查询第一次事故是运营做车主认证活动时出现的。某个晚上数据库慢查询数量突然从每分钟几十条飙升到数万条连接池一度打满。当时先查Redis监控命中率从正常的95%左右掉到55%内存和CPU都正常说明Redis本身没问题。再查数据库慢日志发现大量SQL的WHERE条件里都是不存在的VIN编号比如TS2024A00001这种随机组合。根因是典型的缓存穿透攻击或刷单脚本用批量随机VIN请求查询接口这些VIN在数据库里根本不存在也不会写入缓存。每次请求都绕过Redis直接打到MySQL数据库当然扛不住。修复做了两件事。第一用布隆过滤器挡掉不存在的VIN。我们用Redis的字符串位图实现了一个简易布隆过滤器初始化时把全量有效VIN的hash值写入位图查询时先判定位图是否存在不存在直接返回车辆不存在根本不会访问数据库。关键代码如下import redis, hashlib r redis.Redis(hostcache-cluster, port6379) BIT_MAP_KEY vin:bloom:filter SEEDS [7, 11, 13, 17, 19, 23] def _set_bit(key: str): for seed in SEEDS: pos int(hashlib.md5(f{key}:{seed}.encode()).hexdigest()[:8], 16) % (1024 * 1024 * 1024) r.setbit(BIT_MAP_KEY, pos, 1) def _exists(key: str) - bool: for seed in SEEDS: pos int(hashlib.md5(f{key}:{seed}.encode()).hexdigest()[:8], 16) % (1024 * 1024 * 1024) if not r.getbit(BIT_MAP_KEY, pos): return False return True初始化和增量维护车辆入网时调_set_bit写入对应VIN查询时先用_exists判断返回False直接拒绝。布隆过滤器有误判率但参数合理时误判率极低对业务几乎没有影响。第二对空结果做短暂缓存。即使布隆过滤器误判查询到了数据库但结果为空也把空结果以较短TTL3到5秒写入缓存。后续同样的请求直接命中空缓存不会反复穿透数据库。4.2 热点key打爆单分片为什么CPU只挂了一个节点第二次事故很有意思现象也很隐蔽。某个早高峰运维反馈同一个Redis Cluster节点CPU使用率飙到85%而其他节点只有20%左右。集群整体没有挂但延迟P99从2ms涨到了20ms部分超时导致页面数据刷新失败。我们用redis-cli --hotkeys查热点key发现一个车队聚合查询接口的key在高峰期每秒读写超过1.2万次。这个key的值是一个大型车队的车辆状态集合车队500辆车一次查询取出500个元素频繁的HGETALL把单节点CPU吃满了。其实这不是纯数据的锅而是key设计的问题车辆按车队聚合同一个车队的车辆都属于同一业务域哈希标签让它们全部落在同一个节点上。车队越大单节点压力越大而且是持续性的热点不是瞬时尖峰。修复方案是热点拆分加本地缓存。先把单个大集合key拆成多个分片key比如原key{fleet}:1001:veh_list拆成{fleet}:1001:veh_list:0、:1、:2每个分片只存100辆车。写入时按车辆VIN的某个hash值决定进哪个分片读取时用Pipeline并发取所有分片再聚合。这样做让数据分散到多个slot压力也随之分散。同时对这类高频读取的车队状态在应用服务节点上加了一层本地缓存Caffeine本地过期时间设为3秒。车队页面本来就要等聚合结果3秒内的状态偏差用户无感知。本地缓存命中后Redis热点key的读量直接下降到原来的几十分之一。这次事故给我们最大的教训是Redis Cluster的分片是均匀的但业务流量不一定均匀。热点key一旦形成单节点CPU跑满而其他节点闲置集群扩容并不能解决热点问题只能靠拆分和本地缓存。4.3 大key引发的集群“温和”长尾一个6MB的查询耗时200ms第三次事故不是紧急故障但同样需要重视。我们在日常巡检时用redis-cli --bigkeys发现部分集群节点存在大key最大的一个聚合value超过了6MB。它平时不显眼但每次被查询时Redis需要序列化整块数据再通过网络传输单次耗时200ms到300ms。大key还有个隐形风险如果后面执行DEL删除它Redis主线程会被阻塞住导致节点短暂无响应整个分片的请求都会排队。处理手段是把大value拆小。档案聚合拆成了“基础档案”和“扩展信息”两个key车辆状态集合拆成了多个分片key。读取端要么同时读取两个关键key要么用Pipeline批量取分片再聚合。拆完以后单次查询最大value控制在100KB以内耗时降回1ms水平。这里还想多说一句Redis的慢日志SLOWLOG GET要经常看不要等用户报障才查。大key、慢命令、热key都是可以提前发现的前提是监控指标得埋对。4.4 故障演练与监控指标把事故扼杀在发生前那几次事故之后我们补了一套相对完整的监控和演练机制。核心指标包括缓存命中率、内存淘汰数、过期key数量、cluster_state是否OK、各个分片的CPU和内存占比、慢日志数量、客户端连接数。我们用一个独立的Grafana面板把每个分片的指标分开展示任何分片CPU超过60%就触发告警因为热点往往是从单分片开始的。我们还做了一轮故障演练手动redis-cli -c cluster failover切换一个主节点验证客户端能否在秒级自动切换到从节点。演练过程中发现的坑是部分旧版本客户端在集群拓扑变化后没有及时刷新路由表产生大量MOVED重试后来通过升级客户端版本和设置合理的max-redirections解决。5. 容量评估与压测验证单集群到底能扛多大流量5.1 压测场景与工具选择缓存层上线前我们对集群做了压测用的工具是memtier_benchmark因为它支持多线程、多key和真实比例的读写混合场景比redis-benchmark更能反映业务特征。压测命令参考如下memtier_benchmark -s 10.0.0.11 -p 6379 \ --cluster-mode \ --ratio 7:3 \ --key-pattern S:S \ --requests 2000000 \ --threads 8 \ --clients 40 \ --data-size 512 \ --test-time 120这组参数的含义是开启集群模式读写比例7比3使用顺序小key模式模拟大量小value随机读取的场景。结果非常关键日常读写混合场景下三主集群可以稳定支撑超过30万QPS单节点延迟P99在1.5ms以内写放大场景下纯写可以达到12万QPS延迟P99在2.8ms左右。这个吞吐量对当时三十多万辆车的业务绰绰有余。5.2 手算容量一个可以在十分钟内完成的估算公式很多团队的容量评估要么拍脑袋要么等线上爆了才扩。其实缓存容量可以很简单地手算。我们按公式总内存占用 单key内存 × key数量 × 副本数 × 碎片膨胀系数。以遥测快照为例一个key的Hash结构包含大约20个字段序列化后约2KB。假设未来接入100万辆车主数据2KB × 100万 2GB副本3主3从配置下每个key存2份也就是2GB × 2 4GB碎片和AOF重写临时空间乘以1.5的膨胀系数约6GB这还只算了遥测快照加上档案聚合、在线状态、指令状态等约等于8GB。三主节点每台8GB内存总容量24GB在最坏情况下仍有接近三倍的余量。我们最终把每台节点内存升到16GB因为还要考虑未来车辆数翻倍。容量评估里最常见的错误是只算逻辑大小漏掉Replication的内存占用和Redis内存碎片率。哈希结构在元素不多时底层用listpack存储能省不少内存但元素多了会转成hashtable内存翻倍所以压测时data-size要尽量贴近真实数据。5.3 在线扩容与槽位迁移的实操注意点车辆数增长后三主节点不够用了需要把集群扩到六主六从。Redis Cluster在线扩容相对干净基础流程是新节点以集群模式启动并加入现有集群用redis-cli --cluster reshard 任意节点IP:端口执行槽位迁移为每个迁移的slot指定目标节点确认后开始迁移迁移过程中观察各分片CPU和内存变化避免高峰期执行。槽位迁移期间被迁移的slot上会有短暂的阻塞或延迟升高我们一般选凌晨业务低谷做。这里有个小技巧迁移前先把要接收槽位的新节点内存、CPU余量调整好别等迁移到一半再加配置。5.4 限流与降级缓存只在“可用”时才有价值最后强调一个容易被忽略的点缓存层自身也会故障、也会超时。如果Redis集群整体不可用所有请求就必须立即降级到本地缓存加数据库这个切换机制必须在业务代码里提前写好。我们的做法是给缓存访问封装一层带熔断逻辑的客户端连续100次请求失败或超时超过阈值熔断器打开后续请求直接走数据库兜底路径同时开启定时探测Redis恢复后自动放行。本地缓存仍然保留作为二级守卫把部分重复读挡在应用层之内。这样即便Redis彻底挂掉平台还能以受限模式运行不会完全瘫痪。这套“Redis 本地缓存 数据库”的三级降级链路是我们在多次事故后总结出的底线方案。车联网平台最怕的不是慢是全停只要有兜底路径用户侧的感知就能控制在“偶尔延迟”而不是“彻底不可用”。6. 从容量规划到热点治理这一层缓存带来的额外收益缓存上线并稳定运行之后最直观的收益是数据库连接池不再告警大屏刷新延迟从原来的800ms以上降到200ms以内。但真正让我意外的是分布式缓存不止解决了性能问题还改变了整个数据链路的职责边界实时状态以缓存为准历史数据以数据库和数仓为准上下游团队对“哪份数据才是准的”达成了共识少了很多扯皮。热点治理的经验后来也被复用到了消息链路和API网关层。比如针对热门赛事、节假日活动的流量洪峰我们直接用同样的“拆分加本地缓存”思路做网关层限流效果一样明显。如果你准备在自己的平台上做类似的事我的建议很简单先拆数据类别再定缓存模型最后再做容量评估。顺序反了大概率会走上“缓存命中率低、DB还是被打爆、团队互相甩锅”的老路。缓存不是银弹但它确实给了车联网数据平台一个非常宝贵的缓冲区间让架构师有机会把读和写彻底剥离开。
返回列表