用一个数字存储海量 Tag 标签
Hi,我是阿昌,今天记录一个在业务系统里非常实用的小设计:如何用一个数字去存储很多个 Tag 标签。
这个方案本质上并不复杂,它不是给数据库不停加字段,也不是一上来就设计一张很重的标签关系表,而是利用二进制位来表示标签状态。
这种设计在商品系统、库存系统、用户画像系统里都很常见,尤其适合这种场景:标签很多,但每个标签本质上都只是一个开关。
一、业务背景
在实际业务里,一个商品、一条 SKU 记录、一个用户,经常都会挂很多标签。
比如商品场景里,可能会有这些标记:
- 新品商品
- 推荐商品
- 预售商品
- 库存紧张商品
- 风险商品
- 热销商品
- 平台特殊商品
- 运营处理标记
一开始标签可能只有几个,看起来很好处理。
但随着业务不断迭代,标签数量往往会越来越多,最后可能变成几十个,甚至上百个。
如果一开始采用最直接的设计方式,通常就是在商品表里加很多布尔字段:
1 | is_new_arrival |
这种设计在标签少的时候没什么问题,但一旦标签多起来,问题就会逐渐暴露:
- 表结构会越来越宽
- 每新增一个标签,都可能要改表结构
- 大表做 DDL 有一定风险
- 代码里会出现大量
is_xxx判断,维护成本越来越高
所以这种场景下,就需要一个更轻量、更稳定一点的方案。
这里比较常见的做法就是:位掩码,Bitmask。
二、核心思路
一句话概括这个方案:
每个标签占用二进制的一位,多个标签最终合并成一个数字,再存到数据库里。
比如数据库里用一个 BIGINT 字段,在 Java 里一般就对应 Long。
Long 底层是 64 位二进制,那我们就可以让不同的位分别代表不同的标签。
例如:
1 | newArrival = 1L << 1 = 2 |
这里的 1L << 1,就是让数字 1 左移 1 位。
左移之后,这个值在二进制里就占据了一个独立的位置。
每个标签都使用一个独立位置,这样它们彼此之间就不会冲突。
三、一个简单例子
假设现在一个商品同时有两个标签:
- 新品商品
newArrival - 推荐商品
featured
它们对应的值分别是:
1 | newArrival = 2 |
那最终存到数据库里的值就是:
1 | 2 + 4 = 6 |
如果从二进制角度看,会更直观一些:
1 | newArrival: |
也就是说,数据库里虽然只存了一个数字 6,但这个数字本身就已经表达了多个标签状态。
这就是位掩码的核心思想。
四、如何判断是否包含某个标签
存储时是把多个标签合并成一个数字。
那查询时,怎么判断这个数字里有没有某个标签?
答案就是:按位与,AND。
比如数据库里当前存的是 6,现在我想判断它有没有 featured 标签。
featured 对应的值是 4。
那就做一次按位与:
1 | 商品标签值: |
结果大于 0,说明这个标签存在。
如果判断 presale 呢?
presale 的值是 8。
1 | 商品标签值: |
结果等于 0,说明这个标签不存在。
所以这个判断逻辑可以总结成一句话:
标签值和存储值做按位与,只要结果大于 0,就说明包含该标签。
五、代码里如何实现
在代码层面,比较推荐用一个枚举类来统一管理所有标签。
每个枚举项通常包含这些信息:
- 标签分组
- 标签对应的位值
- 标签名称
示例:
1 | NEW_ARRIVAL(TagGroup.BASIC, 1L << 1, "newArrival"), |
1、判断标签是否存在
1 | public static boolean containsTag(Long maskValue, ProductTag tag) { |
这个方法本身非常简单,但它就是位掩码判断标签的核心。
2、把标签列表转成掩码值
1 | public static Long toMaskValue(Collection<String> tagNames, TagGroup group) { |
这里使用了 sum()。
因为每个标签占据的二进制位都不同,所以这里做加法,最终效果和按位或其实是一致的。
3、把掩码值还原成标签列表
1 | public static List<String> toTagNameList(Long maskValue, TagGroup group) { |
逻辑也很直接,就是把所有标签枚举遍历一遍,逐个做按位与判断,命中的就放进结果集里。
六、数据库里怎么查询
除了代码里可以判断,数据库层面同样也可以直接使用位运算。
比如查询包含某个标签的商品:
1 | SELECT * |
这里的 2 就代表 newArrival 这个标签对应的位值。
如果要查询多个标签,也可以先把多个标签位值合并起来:
1 | SELECT * |
这个 SQL 表示:查询包含 newArrival 或 featured 的商品。
在一些动态 SQL 场景下,也可以让前端把多个标签传给后端,后端在查询时合并成一个掩码值:
1 | AND tag_mask & ( |
这里的关键点就在于:
前端传的是多个标签,后端在数据库里真正过滤的,其实是一个位掩码数字。
七、为什么通常会拆成多个字段
虽然一个 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 标签,并不一定要一个标签对应一个字段。
我们完全可以给每个标签分配一个二进制位,把多个标签合并成一个数字存到数据库里。
判断标签时,用按位与。
新增标签时,只需要在统一的标签定义里分配新的位值即可。
这就是位掩码在海量标签存储场景下最核心的设计思路。
以上就是这次分享记录的全部内容。