用一个数字存储海量 Tag 标签
阿昌 Java小菜鸡

用一个数字存储海量 Tag 标签

Hi,我是阿昌,今天记录一个在业务系统里非常实用的小设计:如何用一个数字去存储很多个 Tag 标签

这个方案本质上并不复杂,它不是给数据库不停加字段,也不是一上来就设计一张很重的标签关系表,而是利用二进制位来表示标签状态。

这种设计在商品系统、库存系统、用户画像系统里都很常见,尤其适合这种场景:标签很多,但每个标签本质上都只是一个开关。


一、业务背景

在实际业务里,一个商品、一条 SKU 记录、一个用户,经常都会挂很多标签。

比如商品场景里,可能会有这些标记:

  • 新品商品
  • 推荐商品
  • 预售商品
  • 库存紧张商品
  • 风险商品
  • 热销商品
  • 平台特殊商品
  • 运营处理标记

一开始标签可能只有几个,看起来很好处理。

但随着业务不断迭代,标签数量往往会越来越多,最后可能变成几十个,甚至上百个。

如果一开始采用最直接的设计方式,通常就是在商品表里加很多布尔字段:

1
2
3
4
5
is_new_arrival
is_featured
is_presale
is_risky
is_low_stock

这种设计在标签少的时候没什么问题,但一旦标签多起来,问题就会逐渐暴露:

  • 表结构会越来越宽
  • 每新增一个标签,都可能要改表结构
  • 大表做 DDL 有一定风险
  • 代码里会出现大量 is_xxx 判断,维护成本越来越高

所以这种场景下,就需要一个更轻量、更稳定一点的方案。

这里比较常见的做法就是:位掩码,Bitmask


二、核心思路

一句话概括这个方案:

每个标签占用二进制的一位,多个标签最终合并成一个数字,再存到数据库里。

比如数据库里用一个 BIGINT 字段,在 Java 里一般就对应 Long

Long 底层是 64 位二进制,那我们就可以让不同的位分别代表不同的标签。

例如:

1
2
3
4
newArrival   = 1L << 1 = 2
featured = 1L << 2 = 4
presale = 1L << 3 = 8
lowStock = 1L << 4 = 16

这里的 1L << 1,就是让数字 1 左移 1 位。

左移之后,这个值在二进制里就占据了一个独立的位置。

每个标签都使用一个独立位置,这样它们彼此之间就不会冲突。


三、一个简单例子

假设现在一个商品同时有两个标签:

  • 新品商品 newArrival
  • 推荐商品 featured

它们对应的值分别是:

1
2
newArrival = 2
featured = 4

那最终存到数据库里的值就是:

1
2 + 4 = 6

如果从二进制角度看,会更直观一些:

1
2
3
4
5
6
7
8
newArrival:
0000 0010

featured:
0000 0100

合并后:
0000 0110

也就是说,数据库里虽然只存了一个数字 6,但这个数字本身就已经表达了多个标签状态。

这就是位掩码的核心思想。


四、如何判断是否包含某个标签

存储时是把多个标签合并成一个数字。

那查询时,怎么判断这个数字里有没有某个标签?

答案就是:按位与,AND

比如数据库里当前存的是 6,现在我想判断它有没有 featured 标签。

featured 对应的值是 4

那就做一次按位与:

1
2
3
4
5
6
7
8
商品标签值:
0000 0110

featured:
0000 0100

按位与结果:
0000 0100

结果大于 0,说明这个标签存在。

如果判断 presale 呢?

presale 的值是 8

1
2
3
4
5
6
7
8
商品标签值:
0000 0110

presale:
0000 1000

按位与结果:
0000 0000

结果等于 0,说明这个标签不存在。

所以这个判断逻辑可以总结成一句话:

标签值和存储值做按位与,只要结果大于 0,就说明包含该标签。


五、代码里如何实现

在代码层面,比较推荐用一个枚举类来统一管理所有标签。

每个枚举项通常包含这些信息:

  • 标签分组
  • 标签对应的位值
  • 标签名称

示例:

1
2
3
4
NEW_ARRIVAL(TagGroup.BASIC, 1L << 1, "newArrival"),
FEATURED(TagGroup.BASIC, 1L << 2, "featured"),
PRESALE(TagGroup.BASIC, 1L << 3, "presale"),
LOW_STOCK(TagGroup.BASIC, 1L << 4, "lowStock");

1、判断标签是否存在

1
2
3
4
5
6
public static boolean containsTag(Long maskValue, ProductTag tag) {
if (maskValue == null) {
return false;
}
return (tag.getValue() & maskValue) > 0L;
}

这个方法本身非常简单,但它就是位掩码判断标签的核心。

2、把标签列表转成掩码值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static Long toMaskValue(Collection<String> tagNames, TagGroup group) {
if (tagNames == null || tagNames.isEmpty()) {
return 0L;
}

Map<String, ProductTag> tagMap = getTagsByGroup(group).stream()
.collect(Collectors.toMap(ProductTag::getName, Function.identity()));

return tagNames.stream()
.distinct()
.filter(tagMap::containsKey)
.mapToLong(tagName -> tagMap.get(tagName).getValue())
.sum();
}

这里使用了 sum()

因为每个标签占据的二进制位都不同,所以这里做加法,最终效果和按位或其实是一致的。

3、把掩码值还原成标签列表

1
2
3
4
5
6
7
8
9
10
11
12
13
public static List<String> toTagNameList(Long maskValue, TagGroup group) {
if (maskValue == null || maskValue == 0L) {
return Collections.emptyList();
}

List<String> result = new ArrayList<>();
for (ProductTag tag : getTagsByGroup(group)) {
if ((tag.getValue() & maskValue) > 0L) {
result.add(tag.getName());
}
}
return result;
}

逻辑也很直接,就是把所有标签枚举遍历一遍,逐个做按位与判断,命中的就放进结果集里。


六、数据库里怎么查询

除了代码里可以判断,数据库层面同样也可以直接使用位运算。

比如查询包含某个标签的商品:

1
2
3
SELECT *
FROM products
WHERE tag_mask & 2;

这里的 2 就代表 newArrival 这个标签对应的位值。

如果要查询多个标签,也可以先把多个标签位值合并起来:

1
2
3
SELECT *
FROM products
WHERE tag_mask & (2 + 4);

这个 SQL 表示:查询包含 newArrivalfeatured 的商品。

在一些动态 SQL 场景下,也可以让前端把多个标签传给后端,后端在查询时合并成一个掩码值:

1
2
3
4
5
AND tag_mask &amp; (
<foreach item="item" collection="includedTagValues" separator=" + ">
#{item}
</foreach>
)

这里的关键点就在于:

前端传的是多个标签,后端在数据库里真正过滤的,其实是一个位掩码数字。


七、为什么通常会拆成多个字段

虽然一个 BIGINT 能存很多位,但它也不是无限的。

Java 里的 long 是有符号数,最高位一般是符号位,不建议直接拿来做业务标签。

如果再保守一点,不使用第 0 位,而是从第 1 位开始分配,那么一个字段大概可以稳定支持 62 个标签。

那如果标签数量超过 62 个怎么办?

这个时候就不建议继续硬塞,而是按照业务语义去拆多个字段。

比如可以拆成下面几类:

字段 类型 含义
basic_tag_mask 基础标签 商品基础属性类标签
business_tag_mask 业务标签 商品运营处理类标签
platform_tag_mask 平台标签 外部平台同步类标签

这样设计有两个好处:

  • 每个字段职责会更清晰
  • 标签数量增长时,也不会把所有语义都混在一个字段里

如果一个字段支持 62 个标签,那三个字段就可以支持接近 186 个标签,扩展空间已经很可观了。


八、新增标签的成本

位掩码方案一个很明显的优点就是:新增标签的成本比较低

通常只需要在标签枚举里补一项定义即可。

例如:

1
NEW_BUSINESS_TAG(TagGroup.BUSINESS, 1L << 18, "newBusinessTag");

只要这个位之前没有被占用,就可以直接使用。

这意味着:

  • 不需要给大表新增字段
  • 不需要做历史数据迁移
  • 不需要为了一个布尔标记发起一次 DDL 变更

对于商品大表、SKU 大表这种场景来说,这一点还是非常有价值的。


九、这个方案的优点

我自己理解,位掩码的优点主要有下面几个:

1、省空间

原本可能需要很多个布尔字段,现在一个 BIGINT 就可以表达。

2、扩展成本低

新增标签主要是维护标签定义,不需要频繁改表。

3、判断效率高

位运算本身非常轻量,适合高频判断。

4、结构更集中

所有标签统一收口在一个枚举或配置定义里,谁占哪一位,管理起来会更清晰。


十、这个方案的限制

当然,这个方案也不是万能的,它有很明确的适用边界。

1、只适合表达“有”或“没有”

如果标签除了开关状态,还需要记录:

  • 打标时间
  • 操作人
  • 标签来源
  • 备注信息

那单纯一个数字就不够了,这时候还是需要额外的标签明细表。

2、位值一旦上线就不能随便改

比如 newArrival 一旦定义成 1L << 1,那它后面就必须一直保持这个值。

否则历史数据对应的业务含义会全部错乱。

3、字段容量有上限

一个字段能承载的位数始终是有限的,所以一开始最好就按业务类型规划好分组。

4、SQL 位运算对索引利用有限

位运算查询不一定像普通等值查询那样容易吃满索引。

所以在真实业务场景里,一般还是会搭配其他过滤条件一起用,比如:

  • 店铺
  • 平台
  • 类目
  • 时间范围
  • 商品状态

这样整体查询性能会更稳一些。


十一、我的理解

我觉得位掩码最适合解决一类问题:

标签数量很多,但每个标签本质上只是一个布尔开关。

如果是这种场景,那它会是一个很实用的设计。

它的价值不在于“技术多炫”,而在于:

  • 存储成本低
  • 扩展成本低
  • 表结构更稳定
  • 对大表更友好

尤其是在商品系统这种标签持续增长、又不想频繁改表的业务里,这种方案非常值得借鉴。


十二、总结

最后简单总结一下。

海量 Tag 标签,并不一定要一个标签对应一个字段。

我们完全可以给每个标签分配一个二进制位,把多个标签合并成一个数字存到数据库里。

判断标签时,用按位与。

新增标签时,只需要在统一的标签定义里分配新的位值即可。

这就是位掩码在海量标签存储场景下最核心的设计思路。

以上就是这次分享记录的全部内容。

 请作者喝咖啡