用 Hashids 优雅解决 C 端自增 ID 暴露问题

   在 C 端系统中,直接对外暴露数据库自增 ID 往往会带来数据枚举、越权访问等安全隐患。本文将从实际业务场景出发,分析自增 ID 暴露的问题本质,并介绍一种基于 Hashids 的可逆 ID 混淆方案。通过 Hashids,我们可以在不改变数据库结构的前提下,实现对外 ID 的安全化与美观化,兼顾安全性、性能与工程可落地性。

本文主要内容:

  • 为什么 C 端系统不能直接暴露自增 ID
  • 解决方案是什么
  • hashids有那些功能
  • hashids的代码实现是什么

为什么 C 端系统不能直接暴露自增 ID?

在后端系统中,我们习惯使用数据库自增 ID,并习惯性的直接返回给C端交互使用,例如:

登录接口在登录成功后返回的用户基础信息
{
    "userId": 100,
    "username": "tom",
    "gender": 1,
    "birthday": "2000-10-12 00:00:00"
}

URL上直接有自增ID:GET /api/user/100

可被枚举

只要有人发现这是自增 ID,例如:GET /api/user/100,自然可以被枚举

/api/user/101

/api/user/102

/api/user/103

......

发现了问题没:

  • 可以轻松遍历接口、爬虫可以批量扫库
  • 哪怕你有登录态、鉴权,只要 权限校验有一个点没兜住,后果极其严重:表数据被批量抓取,隐私数据被遍历、爬光
  • 这类攻击成本极低,甚至不算“攻击”

越权

越权的风险被无限放大,你“以为”你做了鉴权,其实不一定,现实情况往往是:

  • 接口 A 做了用户校验
  • 接口 B 忘了
  • 新接口临时加的,校验漏了
  • 某个内部接口被误暴露

一旦 ID 是可预测的:

  • 攻击者只需要找到 一个没校验的入口
  • 就可以“横向移动”访问所有数据

例如,URL中存在自增ID,在C端非常典型的场景是用户分享链接给朋友,如果朋友修改URL中的ID,就会跳转到本不属于自己能看到的数据内容。

业务信息全暴露

通过 ID 就能看穿你的业务信息,例如:

  • 📈 订单量增长速度

  • 👥 用户规模

  • ⏱️ 业务峰值时段

  • 🧮 是否删过数据(ID 是否断层)

这种在C端用户看来没有意义的数据,如果让用户“看不懂”的 ID,反而更专业。

  • 👉 纯数字 ID,看起来像“内部系统”

  • 👉混淆 ID, 更像“产品设计的一部分”

解决方案

根据以上问题,我们期望有这样一种解决方案可以混淆自增ID

  • 唯一不可重复:数据量内都必须唯一,不能重复
  • 支持可逆:ID可以编码为一个看不出规律的串,也可以解码为原ID,不影响数据库ID字段
  • 高效生成与解析:生成、验证的算法必须保证效率,不能占用太多系统资源
  • 不可预测与安全:无规则混淆,规律性不能很明显,不能轻易被人猜测到,防止爆刷
  • 工程成本低:不改表、不迁数据

常见但不够优雅的解决方案

  • UUID
    • 字符串过长
    • URL、二维码不友好
    • 调试体验差
  • Snowflake / Base64
    • 仍然可能暴露时间信息
    • 前后端实现不统一
  • AES / RSA 加密 ID
    • 性能与复杂度成本高
    • 对“只是隐藏 ID”来说属于过度设计

这个解决方案就是Hashids。

Hashids的核心功能:把一个或多个整数(int / long)转换成一个不可预测、可逆的短字符串。

基本属性

  • 输入是整数(支持long型),输出是字符串(只包含:a-z A-Z 0-9,无其他特殊字符)
  • 可以自定义编码字符
  • 可逆,但不可猜
  • 支持多个ID编码为一个字符串
  • 可控制最小长度,不支持“最长长度”限制,实际长度是不固定的,随输入数字大小变化

典型用途:

  • 数据库自增 ID ,对外展示用字符串,防止 ID 枚举

  • 短链接 / 邀请码 / 兑换码

  • URL / 小程序参数更友好

  • 将多个数字(数组)进行混淆,防止参数被篡改

注意:它是“混淆(obfuscation)”,不是“加密(encryption)”,不能作为密码学类的场景使用。

Hashids内部原理

编码(encdoe)流程:

原始数字 --> 打乱字符表(依赖 salt) --> 选取 guard / separator --> 进制转换(base-N) --> 按规则拼接 --> 输出字符串

核心组成元素

  • 核心组成元素
    • 字符表(alphabet):指定那些字符是输出的结果集
    • 默认字符集:abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890
    • 你也可以自定义(例如只用大小写 + 数字,排除 0/O/I/l),注意字符不能重复。
    • 字符集长度 = 进制 base N
  • salt
    • 决定字符表如何被洗牌
    • 同一个 ID,在不同 salt 下,结果完全不同
    • 不保存 salt,就无法反解
  • separators & guards
    • Hashids 会从 alphabet 中分出两类特殊字符:separators,分隔多个数字。guards,控制最小长度、增强不可预测性。
    • 这一步主要是为了:避免输出模式过于规则,同时支持编码多个数字的场景(encode(1,2,3))

怎么实现可逆性的?

  • 字符表洗牌(consistent shuffle)
  • 在相同 alphabet + salt下,编码同一ID,结果永远相同
查看代码
for i from alphabet.length-1 downTo 1:
    j = (salt_char_code + i + previous) % i
    swap(alphabet[i], alphabet[j])

怎么转换为字符串的?

  • 假设字符表(alphabet)的长度为62
  • 数字先模62,得到的余数作为下标从字符表中取得一个字符
  • 再除62,直到数字小于0时停止
  • 得到一个字符串

如何支持同时编码多个数字?

例如:encode(1, 2, 3)

1 → abc
2 → k9
3 → z

中间用 separator(也是来自 alphabet,但经过专门筛选)隔开,最终输出:abcXk9Yz

怎么保证最小长度?

  • 在头尾插入 guard

  • 再次洗牌 alphabet

  • 重复直到满足长度,这一步是伪随机填充,不影响 decode。

decode的工作流程?

  • 去掉 guards

  • 用 separators 切分

  • 复原 alphabet 洗牌

  • 每一段做 base-N → long

decode失败怎么处理?

  • salt 不一致 → decode 失败或得到错误值
  • alphabet 不一致 → decode 失败

代码实现

终于到了激动人心的代码实现环节,撸起袖子,敲键盘。

在pom中导入依赖

<dependency>
    <groupId>org.hashids</groupId>
    <artifactId>hashids</artifactId>
    <version>1.0.3</version>
</dependency>

简单用法

import org.hashids.Hashids;
import org.springframework.stereotype.Service;

import java.util.Arrays;

@Service
public class HashidsService {

    //Bean单例,不存在线程安全问题
    private final Hashids hashids = new Hashids();

    public String encode(int code) {
        return hashids.encode(code);
    }

    public String encode(long code) {
        return hashids.encode(code);
    }

    public long decode(String decoded) {
        long[] decodes = hashids.decode(decoded);
        if (decodes.length == 0) {
            throw new IllegalArgumentException("非法ID");
        }
        return decodes[0];
    }

    public String encodeArr(int[] codes) {
        long[] codeArr = Arrays.stream(codes).asLongStream().toArray();
        return hashids.encode(codeArr);
    }

    public String encodeArr(long[] codes) {
        return hashids.encode(codes);
    }

    public long[] decodeArr(String decoded) {
        long[] decodes = hashids.decode(decoded);
        if (decodes.length == 0) {
            throw new IllegalArgumentException("非法ID");
        }
        return decodes;
    }

}

加盐

加盐混淆

  • 防止别人用同样库解你 ID
  • salt 一旦上线 绝对不能改
# application.yml
hashids:
  salt: kjsdfiaosudkskldjfa #混淆用的盐
  min-length: 8 #最小长度
查看代码
package com.ks.demo.uc.hashids;

import org.hashids.Hashids;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.util.Arrays;

/**
 * 加盐混淆
 *
 * salt的作用
 * 防止别人用同样库解你 ID
 * salt 一旦上线 绝对不能改
 */
@Service
public class HashidsSaltService {
    @Value("${hashids.salt}")
    private String salt;
    @Value("${hashids.min-length}")
    private int minLength;

    //new在@Value注入之前
    //解决方案:后构造器,在构造器的入参使用@Value,使用@ConfigurationProperties单独注入
    //private Hashids hashidsSalt = new Hashids(salt);

    private Hashids hashidsSalt = null;
    private Hashids hashidsMinLen = null;

    @PostConstruct
    public void init() {
        hashidsSalt = new Hashids(salt);
        hashidsMinLen = new Hashids(salt, minLength);
    }

    public String encode(int code) {
        return hashidsSalt.encode(code);
    }
    public String encode(long code) {
        return hashidsSalt.encode(code);
    }
    public long decode(String decoded) {
        long[] decodes = hashidsSalt.decode(decoded);
        if (decodes.length == 0) {
            throw new IllegalArgumentException("非法ID");
        }
        return decodes[0];
    }

    public String encodeArr(int[] codes) {
        long[] codeArr = Arrays.stream(codes).asLongStream().toArray();
        return hashidsSalt.encode(codeArr);
    }
    public String encodeArr(long[] codes) {
        return hashidsSalt.encode(codes);
    }
    public long[] decodeArr(String decoded) {
        long[] decodes = hashidsSalt.decode(decoded);
        if (decodes.length == 0) {
            throw new IllegalArgumentException("非法ID");
        }
        return decodes;
    }

    public String encodeMinLen(int code) {
        return hashidsMinLen.encode(code);
    }
    public String encodeMinLen(long code) {
        return hashidsMinLen.encode(code);
    }
    public long decodeMinLen(String decoded) {
        long[] decodes = hashidsMinLen.decode(decoded);
        if (decodes.length == 0) {
            throw new IllegalArgumentException("非法ID");
        }
        return decodes[0];
    }

    public String encodeMinLenArr(int[] codes) {
        long[] codeArr = Arrays.stream(codes).asLongStream().toArray();
        return hashidsMinLen.encode(codeArr);
    }
    public String encodeMinLenArr(long[] codes) {
        return hashidsMinLen.encode(codes);
    }
    public long[] decodeMinLenArr(String decoded) {
        long[] decodes = hashidsMinLen.decode(decoded);
        if (decodes.length == 0) {
            throw new IllegalArgumentException("非法ID");
        }
        return decodes;
    }
}

自定义参与编码的字符

字符集规则:

  • 至少 16 个字符
  • 不允许重复字符
# application.yml
hashids:
  salt: kjsdfiaosudkskldjfa #混淆用的盐
  min-length: 8 #最小长度
  #至少 16 个字符,不允许重复字符
  #参与编码的字符,可以剔除调0/O/o,1/I/l等字符,增强可读性
  base-char: 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
查看代码
package com.ks.demo.uc.hashids;

import org.hashids.Hashids;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.util.Arrays;

/**
 * 自定义参与编码的字符
 *
 * 字符集规则:
 * 至少 16 个字符
 * 不允许重复字符
 *
 * 使用场景
 * 避免 0/O、l/1 混淆
 * 只允许大写字母
 *
 */
@Service
public class HashidsBaseCharService {
    @Value("${hashids.salt}")
    private String salt;
    @Value("${hashids.min-length}")
    private int minLength;
    @Value("${hashids.base-char}")
    private String baseChar;

    private Hashids hashids = null;

    @PostConstruct
    public void init() {
        hashids = new Hashids(salt, minLength, baseChar);
    }

    public String encode(int code) {
        return hashids.encode(code);
    }
    public String encode(long code) {
        return hashids.encode(code);
    }
    public long decode(String decoded) {
        long[] decodes = hashids.decode(decoded);
        if (decodes.length == 0) {
            throw new IllegalArgumentException("非法ID");
        }
        return decodes[0];
    }

    public String encodeArr(int[] codes) {
        long[] codeArr = Arrays.stream(codes).asLongStream().toArray();
        return hashids.encode(codeArr);
    }
    public String encodeArr(long[] codes) {
        return hashids.encode(codes);
    }
    public long[] decodeArr(String decoded) {
        long[] decodes = hashids.decode(decoded);
        if (decodes.length == 0) {
            throw new IllegalArgumentException("非法ID");
        }
        return decodes;
    }

}

结尾

在文章最后,尝试对自己提问一下问题,来检验你是否真正了解到了本文的核心内容。

  1. C 端自增 ID 暴露会有那些问题?
  2. hashids的核心特性?为什么可以作为混淆自增id的解决方案?
  3. hashids的功能有那些?
  4. 代码怎么写?

本文结束。

posted @ 2026-02-04 21:08  ALGO阿狗  阅读(0)  评论(0)    收藏  举报