刷新后点赞全变 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 全局转字符串,再把点赞入口加上存在性校验,最后清掉历史脏数据,你会发现这个问题结束得非常干脆,甚至有点爽。

相关推荐
吴佳浩3 小时前
Python入门指南(七) - YOLO检测API进阶实战
人工智能·后端·python
廋到被风吹走3 小时前
【Spring】常用注解分类整理
java·后端·spring
货拉拉技术4 小时前
出海技术挑战——Lalamove智能告警降噪
人工智能·后端·监控
最贪吃的虎4 小时前
Git: rebase vs merge
java·运维·git·后端·mysql
用户47949283569154 小时前
给客户做私有化部署,我是如何优雅搞定 NPM 依赖管理的?
前端·后端·程序员
间彧4 小时前
混沌工程在SpringBoot项目中的实践与应用
后端
隔壁阿布都4 小时前
使用LangChain4j +Springboot 实现大模型与向量化数据库协同回答
人工智能·spring boot·后端