刷新后点赞全变 0?别急着怪 Redis,这八成是 Long 被 JavaScript 偷偷“改号”了(一次线上复盘)

做社区功能的人,多半都经历过这种抓狂时刻:你在帖子上点了个赞,按钮立刻高亮,数字也加一,用户体验看起来很丝滑;可你一刷新页面,点赞数像被人清空了一样,全部回到 0。你打开 Redis 客户端看,计数 key 明明存在,值也不是 0。于是你开始怀疑缓存一致性,怀疑是不是读了另一台 Redis,怀疑线上 jar 没更新,甚至怀疑自己是不是在梦里写代码。

我得说,这类问题最阴的地方就在于它特别像缓存问题,实际上却往往跟缓存一点关系都没有。真正的凶手是数据类型边界,准确地说,是 Java 的 long 雪花 id 进了 JavaScript 的 Number 世界以后超过安全整数范围,发生了精度丢失,导致你点赞写入用的是一个"被四舍五入后的 id",刷新读取用的又是"真实 id"。写入和读取压根不是同一个目标,你换十台 Redis 也救不了。

我当时定位这个问题的切入点很朴素,不靠猜,全靠证据。把浏览器 Network 打开,盯住两处就够了:列表或者详情接口返回的帖子 id,和点赞接口请求路径里带的 id。正常情况下它们应该一模一样,哪怕多一个字符都不行。结果我看到的非常刺眼,点赞请求打的是 .../posts/2003003909205840000/like,列表返回的真实 id 却是 2003003909205839874。你看这俩数字差得不多,甚至肉眼一滑可能以为一样,但在业务上它们就是两个完全不同的帖子。更关键的是,这种"差一点点"的差法特别典型,末尾被抹平,被凑整,被改号,这是 JavaScript 精度丢失的指纹。

原因在于 JavaScript 的 Number 是双精度浮点数,安全整数上限是 2^53-1,也就是 9007199254740991。雪花 id 动辄十八十九位,早就远远超过这个范围。超过之后,JS 不是直接报错,它会用一个最接近的可表示值来存,于是你以为你拿到了 2003003909205839874,实际上它在内存里已经变成了另一个相邻但不同的数。前端把这个"变形 id"拼进 URL 发给后端,后端又是强类型世界,收到什么就当什么,于是 Redis 的 key 写成 forum:like:count:1:2003003909205840000,数据库里 target_id 也可能跟着写错。刷新页面时,列表接口从数据库读出真实帖子,再把真实 id 返回给前端,前端用真实 id 去查计数,当然查不到,因为计数全在另一个 id 上,于是你看到的就是"刷新后全是 0"。这不是缓存不一致,这是写错对象了,属于逻辑层面彻底错位。

这种坑之所以容易把人带沟里,是因为点赞当下看起来"生效"。很多实现会在点击后先做乐观更新,UI 先加一,给用户即时反馈。再加上 Redis 里确实有 key 有值,你就更容易误判成"读取链路有问题"。但只要你把写入 id 和读取 id 对齐看一眼,真相就很干净,干净得甚至有点残忍。

修复这类问题,我的经验是别在前端做花活,工程上最稳的路线是后端把所有 Long 统一序列化成字符串,让 id 从一开始就别进 JS 的 Number 世界。只要 JSON 里 id 带引号,前端拿到的就是 string,拼 URL 比较相等都不会丢精度。Spring Boot 里用 Jackson 做全局配置就行,把 Long 和 long 都用 ToStringSerializer 输出,类似这样:

java 复制代码
package com.meme.generator.config;

import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class JacksonConfig {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() {
        return builder -> {
            builder.serializerByType(Long.class, ToStringSerializer.instance);
            builder.serializerByType(Long.TYPE, ToStringSerializer.instance);
        };
    }
}

上线之后你会很直观地看到变化,接口返回从 id: 200300... 变成 "id":"200300..."。这一步就是治本,因为你把风险源头掐掉了。前端这边也别犹豫,所有 id 一律当字符串处理,路由参数、请求参数、Map key、对比逻辑都用 string,别手贱去 Number(id)。如果你用 TypeScript,把 DTO 里的 id 类型直接写死成 string,能提前拦住一堆无意的隐式转换。

光把 Long 转字符串还不够,我习惯再加一道保险,防止系统继续被脏请求污染。点赞接口在真正写 Redis 和写 DB 之前,先校验 targetId 必须真实存在。原因很现实,就算你前端全修好了,也总会有人抓包乱打接口,或者旧版本前端还在外面跑,甚至某些数据链路中间又把 id 搞成了 number。你不想 Redis 和点赞表里永远躺着一堆"幽灵帖子"的点赞记录,最好的办法就是入口处把门关死,不存在的帖子或评论直接拒绝,别写入任何东西。这样系统的容错会强很多,你以后做统计、排行、消息通知也不至于被脏数据恶心。

修复上线以后验证也很简单,不需要写什么复杂用例。你打开 Network 看一眼列表接口响应,只要 id 带引号,基本可以确认你已经跨过了 JS 精度这个坑。如果你发现线上还是数字 id,那就别往下测了,说明你压根没部署到新版本或者配置没生效。接着随便点个赞,观察点赞请求路径里的 id 是否和列表返回完全一致,然后刷新页面看 likeCount 是否还能保持一致。如果这三件事都稳了,这个问题就可以宣布结束,属于"永不复发"那一类。

最后别忘了历史脏数据。这个坑一旦发生过,你 Redis 里很可能已经存在一批错误 id 的计数 key,数据库里也可能已经有 target_id 指向不存在目标的点赞记录。新版本不会再产生,但旧的如果不清理,迟早会在某个统计功能里反咬你一口。清理的思路也不复杂,本质上就是删掉所有指向不存在目标的点赞记录。举个常见场景,如果你的点赞表是 forum_like,帖子表是 forum_post,并且 target_type=1 代表帖子,你可以在确认备份和测试之后用类似的 SQL 找并删除"不存在帖子"的点赞记录:

sql 复制代码
DELETE fl
FROM forum_like fl
LEFT JOIN forum_post fp ON fp.id = fl.target_id
WHERE fl.target_type = 1
  AND fp.id IS NULL;

Redis 清理也建议用 SCAN 去做渐进删除,别在生产上用 KEYS 图省事,Redis 这玩意你真把它阻塞了,后果比点赞错位严重多了。

写到这里你会发现,这次事故真正的教训其实很工程化:跨语言系统里,id 这种一旦出错就会写错对象的字段,永远不要把它当数值去传。尤其是雪花 long,在 Java 世界里再自然不过,到了 JS 世界里就是个随时可能变形的"危险品"。把 Long 输出成字符串不是洁癖,是对线上稳定性的尊重。

如果你现在也被"刷新后点赞全是 0"折磨着,我建议你先别急着调 Redis 和部署链路,先做一件最便宜但最有效的事:对比一下列表返回的 id 和点赞请求里的 id 是否完全一致。只要它们差一点点,你就已经抓到凶手了。把 Long 全局转字符串,再把点赞入口加上存在性校验,最后清掉历史脏数据,你会发现这个问题结束得非常干脆,甚至有点爽。

相关推荐
赵文宇几秒前
CNCF Dragonfly 毕业啦!基于P2P的镜像和文件分发系统快速入门,在线体验
后端
程序员爱钓鱼19 分钟前
Node.js 编程实战:即时聊天应用 —— WebSocket 实现实时通信
前端·后端·node.js
Libby博仙1 小时前
Spring Boot 条件化注解深度解析
java·spring boot·后端
源代码•宸1 小时前
Golang原理剖析(Map 源码梳理)
经验分享·后端·算法·leetcode·golang·map
小周在成长1 小时前
动态SQL与MyBatis动态SQL最佳实践
后端
瓦尔登湖懒羊羊2 小时前
TCP的自我介绍
后端
小周在成长2 小时前
MyBatis 动态SQL学习
后端
子非鱼9212 小时前
SpringBoot快速上手
java·spring boot·后端
我爱娃哈哈2 小时前
SpringBoot + XXL-JOB + Quartz:任务调度双引擎选型与高可用调度平台搭建
java·spring boot·后端
JavaGuide2 小时前
Maven 4 终于快来了,新特性很香!
后端·maven