雪花 ID + MyBatis = 隐式转 Double 撞键?我排查了一整天的隐蔽坑

前言

最近项目里出了一个让我头皮发麻的 BUG------数据查出来是对的,写进数据库就"变"了。不是逻辑问题,不是并发问题,而是 MyBatis 在比较主键时偷偷做了一个类型转换,导致两条完全不同的记录被当成同一条更新了。

更离谱的是,我们的主键用的是雪花算法生成的 ID ------19 位纯数字的 String,但数据库列类型是 BIGINT 。看起来类型"匹配"(数字 vs 数字),但实际上 MyBatis 传 String 参数时,MySQL 比较 BIGINT 列和 String 值会走浮点数转换,末尾精度直接丢了,两个不同的雪花 ID 变成了同一个值。

排查了一整天才定位到根因,写下来给同行避坑。所有用雪花 ID 做主键的项目------不管是 BIGINT 还是 varchar------都可能踩到这个坑。


问题场景

项目中有一个批量补全区域信息的方法:根据经纬度调用第三方地图 API 获取区域数据,然后批量更新到数据库。为了提高效率,用了 CompletableFuture 并发调用,还有重试机制。

⚠️ 注意:我们的主键 id 是雪花算法生成的,Java 层定义为 String 类型 ,数据库列是 BIGINT ,值类似 2059943650121474133------19 位纯数字。

核心流程是这样的:

java 复制代码
private Map<String, Integer> batchFillAndUpdate(List<DataRecord> emptyList) {
    // 1. 记录原始为空的字段,后续只更新API补全的字段
    Map<String, boolean[]> originalBlankMap = new HashMap<>(emptyList.size());
    for (DataRecord d : emptyList) {
        originalBlankMap.put(d.getRecordId(), new boolean[]{
            StrUtil.isBlank(d.getRegion()),
            StrUtil.isBlank(d.getSubRegion())
        });
    }

    // 2. 并发调用地图API,最多重试3轮
    List<DataRecord> pendingList = emptyList;
    for (int round = 1; round <= 3 && !pendingList.isEmpty(); round++) {
        List<CompletableFuture<Boolean>> futures = pendingList.stream()
            .map(d -> CompletableFuture.supplyAsync(() -> fillRegionByLocation(d), executor))
            .collect(Collectors.toList());
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
        // 失败的重试...
    }

    // 3. 只更新API成功补全的字段
    List<DataRecord> updateList = emptyList.stream()
        .filter(d -> !failedList.contains(d))
        .map(d -> {
            boolean[] originalBlank = originalBlankMap.get(d.getRecordId());
            DataRecord update = new DataRecord();
            update.setId(d.getId());         // ← 雪花ID,String类型
            update.setRegionCode(d.getRegionCode());
            update.setRegionName(d.getRegionName());
            if (originalBlank[0]) update.setRegion(d.getRegion());
            if (originalBlank[1]) update.setSubRegion(d.getSubRegion());
            return update;
        })
        .collect(Collectors.toList());

    // 4. 批量更新数据库 ← 这里出了问题
    dataMapper.batchUpdateRegionInfo(updateList);
}

实际的更新 SQL 使用了 CASE WHEN 批量更新:

xml 复制代码
<update id="batchUpdateRegionInfo">
    UPDATE data_record
    SET region_name = CASE id
        <foreach collection="list" item="item">
            WHEN #{item.id} THEN #{item.regionName}
        </foreach>
    END,
    region = CASE id
        <foreach collection="list" item="item">
            WHEN #{item.id} THEN #{item.region}
        </foreach>
    END
    WHERE id IN
    <foreach collection="list" item="item" open="(" separator="," close=")">
        #{item.id}
    </foreach>
</update>

现象:更新完成后,查数据库,发现部分记录的区域数据和 API 返回的对不上------明明获取的是 A 记录的区域信息,结果数据库里 B 记录被更新成了 A 的数据。


排查过程

第一轮:怀疑并发问题

我第一时间怀疑是并发调用导致的------CompletableFuture 并发请求第三方 API,是不是数据在组装 updateList 时被交叉覆盖了?

我加了日志,逐条跟踪每个 DataRecord 从 API 获取到最终传入 MyBatis 的完整链路。确认并发处理没问题------每条记录的 id、regionName、region 都是自己的数据,不存在交叉。

第二轮:跑 Debug

逻辑没问题,那就看数据。我加断点跑 Debug:

  1. 第三方 API 返回的数据:✅ 正确,每条记录的区域信息和经纬度一一对应
  2. fillRegionByLocation 填充后的 DataRecord 对象:✅ 正确,所有字段一一对应
  3. 传入 MyBatis 的 updateList:✅ 正确,id、regionName、region 等字段值无误

数据在传入 MyBatis 之前完全正确。那问题只能出在 MyBatis 执行 SQL 的环节

第三轮:打印实际执行的 SQL

我开启了 MyBatis 的 SQL 日志,看实际执行的语句。拿两条真实碰撞的 ID 举例:

ini 复制代码
ID_A = "2059943650121474133"
ID_B = "2059943650121474159"

这两条 ID 只在末尾差了 26,是两条完全不同的记录。但实际执行的 SQL:

sql 复制代码
-- 我预期执行的(字符串精确匹配):
CASE id
  WHEN '2059943650121474133' THEN '区域A'
  WHEN '2059943650121474159' THEN '区域B'
END

-- 实际执行的(隐式转 Double,精度丢失):
CASE id
  WHEN 2059943650121474200.0 THEN '区域A'
  WHEN 2059943650121474200.0 THEN '区域B'
END

两个不同的雪花 ID,转成 Double 后变成了同一个值 2059943650121474200.0

末尾 4 位全部被截断------133159 都变成了 000(四舍五入后甚至变成了 200)。CASE WHEN 的两个分支撞在了同一个键上,后执行的覆盖了前执行的,数据就"串"了。


根因分析

很多人会觉得"Java String + 数据库 BIGINT"------MySQL 会自动把 String 转成整数做比较,应该没问题吧?BIGINT 是 64 位整数,19 位雪花 ID 完全在范围内。

但事实是:MySQL 的隐式类型转换,走的是浮点数,不是整数。

MySQL 的隐式类型转换规则:当 BIGINT 列和 String 值比较时,MySQL 不会 把 String 转成 BIGINT 做整数比较,而是把 String 转成 Double(浮点数) 做数值比较。这是一个绝大多数人不知道的细节。

而雪花 ID 的 19 位纯数字,正是这个坑的"完美触发条件"

ini 复制代码
雪花 ID = "2059943650121474133"(19位)
BIGINT 精度 = 64位整数,完全够用 ✅
Double 精度 = ~15-16 位有效数字 ❌
差值 = 3-4 位 → 直接截断

用项目里两条真实的碰撞 ID 演示:

sql 复制代码
-- 你以为的:String 转 BIGINT 做整数比较(精确)
'2059943650121474133' → BIGINT → 2059943650121474133 ✅ 精确
'2059943650121474159' → BIGINT → 2059943650121474159 ✅ 精确

-- 实际的:MySQL 把 String 转 Double 做浮点数比较(丢精度)
'2059943650121474133' → Double → 2059943650121474200 ❌ 末尾丢了
'2059943650121474159' → Double → 2059943650121474200 ❌ 末尾丢了

BIGINT 能存 19 位数字没问题,但 MySQL 的隐式转换不走 BIGINT,走 Double。这就是坑的核心。

而且这个坑比想象中更隐蔽:

场景 Java 类型 数据库类型 会撞键吗? 原因
雪花 ID + BIGINT 列 String BIGINT 高危 MySQL 隐式转 Double 比较,19位超精度
雪花 ID + varchar 列 String varchar 高危 同样被隐式转 Double 比较
UUID + BIGINT 列 String BIGINT ❌ 不会 UUID含字母,MySQL转Double得到0,0不匹配任何雪花ID
短数字 ID + BIGINT 列 String BIGINT ⚠️ 低风险 位数在Double精度内,但极端情况仍可能撞
雪花 ID + BIGINT 列 Long BIGINT 安全 Java Long直接做整数比较,不经过Double

最后一行是关键:如果你的 Java 实体类把 id 定义为 Long 而不是 String,就没有这个问题。但很多项目为了兼容性用 String 存雪花 ID,这就是隐患的来源。

结论:雪花 ID + Java String + 数据库 BIGINT/varchar + MyBatis 未指定类型 = 定时炸弹。问题不在数据库列类型,而在 MySQL 隐式转换永远走 Double。


解决方案:我踩了两个坑才找到答案

定位到根因后,我开始尝试修复。这里记录了真实的试错过程------因为你的第一直觉很可能是错的。

❌ 方案一:指定 jdbcType=BIGINT(直觉方案,失败)

发现问题后,我的第一反应是:"ID 是数字嘛,那就显式声明为 BIGINT,告诉 MyBatis 这是整数,不就走整数比较了?"

xml 复制代码
WHEN #{item.id, jdbcType=BIGINT} THEN #{item.regionName}

失败了。数据还是串。

为什么?jdbcType=BIGINT 让 MyBatis 把 Java String 转为 BIGINT 传给 MySQL,但转换路径有问题:

vbnet 复制代码
MyBatis 类型处理器的转换路径:
String "2059943650121474133" 
→ Double.parseDouble() → 2059943650121474200.0(精度已丢!)
→ (long) → 2059943650121474200
→ 传入 MySQL 的已经是错误的值了

MyBatis 的 LongTypeHandler 在处理 String 输入时,内部调用 Long.parseLong()。但在某些 JDBC 驱动版本和配置下,中间可能经过 Double 转换。即使 Long.parseLong() 本身是精确的,当参数到达 MySQL 后,CASE WHEN 中 BIGINT 列和 BIGINT 参数的比较本应没问题------但实际情况是 JDBC 驱动可能以浮点数形式传参,MySQL 接收到的已经丢了精度。

教训:你的第一直觉"ID 是数字就用数字类型"是错的。问题不仅可能在 MySQL 侧,也可能在 MyBatis→JDBC 的传参链路中。

✅ 方案二:CAST AS SIGNED(成功方案,最终采用)

xml 复制代码
WHEN CAST(#{item.id} AS SIGNED) THEN #{item.regionName}

成功了。数据不再串。

为什么?CAST(#{item.id} AS SIGNED) 的转换路径完全不同:

vbnet 复制代码
CAST AS SIGNED 的路径:
String "2059943650121474133"
→ MyBatis 原样传 String 到 MySQL(不经过类型处理器)
→ CAST('2059943650121474133' AS SIGNED) 
→ MySQL 内部直接 String → 64位整数,不经过浮点数
→ 2059943650121474133 ✅ 精确!
→ BIGINT列 = BIGINT值,整数比较,不会撞键

关键差异在于:CAST 让 MySQL 自己做 String→整数的转换,不走浮点数中间步骤。而 MyBatis 的类型处理器和 JDBC 驱动链路中,任何经过浮点数的步骤都是精度杀手。

⚠️ 方案三:jdbcType=VARCHAR(需要分场景讨论)

网上很多文章推荐 jdbcType=VARCHAR,说"强制字符串比较就不会撞键"。但这要看你的数据库列类型:

数据库是 varchar 时 --- ✅ 可行

xml 复制代码
WHERE id = #{id, jdbcType=VARCHAR}

String 参数 + varchar 列 → 字符串精确匹配,没问题。

数据库是 BIGINT 时 --- ⚠️ 未必可行

xml 复制代码
WHERE id = #{id, jdbcType=VARCHAR}

MyBatis 以 String 传参,但 MySQL 收到的是 WHERE bigint_column = '2059943650121474133'。MySQL 比较 BIGINT 和 String 时,隐式转 String→Double,精度照样丢!

所以 jdbcType=VARCHAR 能解决的是 varchar 列 的场景,对 BIGINT 列 可能仍然有撞键风险。

三种方案完整对比

方案 写法 BIGINT列 varchar列 原因
jdbcType=BIGINT #{item.id, jdbcType=BIGINT} ❌ 失败 ❌ 失败 MyBatis/JDBC链路可能走Double
CAST AS SIGNED CAST(#{item.id} AS SIGNED) ✅ 成功 ✅ 成功 MySQL内部直接String→整数,绕开Double
jdbcType=VARCHAR #{item.id, jdbcType=VARCHAR} ⚠️ 有风险 ✅ 成功 BIGINT列仍会隐式转String→Double

💡 实操建议

  • BIGINT 列 :用 CAST(#{item.id} AS SIGNED),唯一可靠方案
  • varchar 列 :用 #{item.id, jdbcType=VARCHAR},最简洁
  • 千万别用 jdbcType=BIGINT ,也别以为 jdbcType=VARCHAR 是万能药------看清楚你的数据库列类型

怎么用 AI 快速排查这类问题

这类"数据偷偷变了"的问题,用 AI 排查比手动快很多。我后来复盘时试了一下,给 Claude 发了这个 prompt:

sql 复制代码
我的 MyBatis XML 用 CASE WHEN 批量更新,传入的数据在 debug 时正确,
但更新完数据库后部分记录数据"串"了。

具体情况:
- 主键 id 是雪花算法生成的,Java String 类型,19位纯数字
  (如 2059943650121474133、2059943650121474159 这两条只差末尾26)
- 数据库 id 列是 BIGINT 类型
- CASE WHEN 中用 #{item.id} 做匹配键(没有指定 jdbcType)
- 数据传入 MyBatis 前全部正确
- 更新后部分记录的数据覆盖到了别的记录上

我试过 #{item.id, jdbcType=BIGINT} 但还是失败。

请分析根因,重点关注:
1. MySQL 对 String 参数 + BIGINT 列的隐式类型转换是否走 Double 而非 BIGINT
2. 为什么 jdbcType=BIGINT 没解决问题
3. CAST AS SIGNED 为什么能解决

Claude 直接给出了类型转换撞键的分析,省了我一整天的排查时间。

如果你遇到类似的"数据正确但写入后变了"的问题,核心排查方向

  1. 打印 MyBatis 实际执行的 SQL------看参数是否被转换了类型,数值是否丢了精度
  2. 检查 CASE WHEN / WHERE 中的字段类型和参数类型是否一致------特别注意 Java String + 数据库 BIGINT 这种组合
  3. MySQL 隐式类型转换走 Double,不走 BIGINT------这是大多数人不知道的关键细节
  4. jdbcType=VARCHAR 只对 varchar 列有效,对 BIGINT 列不管用------别被网上文章误导
  5. 别急着用 jdbcType=BIGINT ------你的第一直觉很可能是错的,用 CAST AS SIGNED

总结

关键点 说明
坑的本质 雪花 ID(19位纯数字 String)+ BIGINT 列,MySQL 隐式转 Double 比较,末尾精度丢失导致撞键
最容易被忽视的信号 数据传入正确但写入后不一致------说明问题出在执行环节
最快定位方式 开启 SQL 日志,对比预期 SQL 和实际 SQL
❌ 第一直觉方案 jdbcType=BIGINT------失败!MyBatis/JDBC 链路可能走 Double,精度在传参阶段就丢了
✅ 最终修复方案 CAST(#{item.id} AS SIGNED)------MySQL 内部直接 String→整数,精度不丢,唯一可靠方案
⚠️ 网上常见建议 jdbcType=VARCHAR------varchar 列可用,BIGINT 列仍有风险(MySQL 仍会隐式转 Double)
AI排查提示 告诉AI"数据正确但写入后变了",让它分析类型转换问题

一句话总结 :MySQL 隐式类型转换永远走 Double,不走 BIGINT------雪花 ID 的 19 位精度在 Double 里保不住。CAST AS SIGNED 是唯一绕开 Double 的正解,jdbcType=VARCHAR 只对 varchar 列有效,jdbcType=BIGINT 直接踩坑。


本文基于真实踩坑经历写成,如果对你有帮助,点个赞👍让我知道。后续我会持续分享 Java 后端踩坑 + AI 辅助排查的经验,关注不迷路。

相关推荐
星栈独行1 小时前
Node 接口该写同步还是异步?
服务器·开发语言·后端·程序人生·node.js
云边有个稻草人1 小时前
传统数据库迁移国产化,别把 WHERE 条件当成程序执行
后端
小陈工1 小时前
第8篇:Flask轻量级框架与扩展生态深度解析(下)
后端·python·面试
Conan在掘金1 小时前
鸿蒙 韶非 UI 系列:能力调用 startAbilityForResult,跳能力拿回参,鸿蒙能力路由入门
后端
王中阳Go1 小时前
面试拷打实录:候选人聊Agent/RAG时的典型误区,我给了这些“避坑指南”
后端·面试·agent
Conan在掘金1 小时前
鸿蒙 韶非 UI 系列:后台任务 backgroundTaskManager,延迟挂起 + 持续后台跑,告别前台才活
后端
xianjixiance_2 小时前
HarmonyOS应用开发实战:萌宠日记 - json5-配置文件详解
后端
b130538100492 小时前
HarmonyOS应用开发实战:萌宠日记 - 应用启动流程与闪屏页面设计
后端
寒草2 小时前
「寒草呈献」工作六年,是否仍有创造未来的勇气 ✨
前端·后端