0%

bitmap的应用—内容标签场景的存储方案

在之前特意穿插了一篇介绍bitmap的笔记bitmap与布隆过滤器,这里将介绍一个bitmap的应用场景,为了避免可能的风险,将具体的业务替换成了类似的场景。

需要开发一个图片标签功能:

  • 用户发布图片时,可以为图片选择预置标签或新增标签;已发布的图片可以增加/移除标签;一张图片的标签不重复,标签数量限制不能完全确定但是不会很多(十个以内)
  • 系统预置标签主要是常用的、较为标准化的,如二次元、水墨画、动漫风等。系统预置标签的文本可以修改,比如 动漫–>动漫风
  • 对用户新增的标签,不能再修改或删除标签内容;标签任意人可以使用,标签文本一致即认为相同
  • 可以按单个标签或者多个标签联合使用(交、并、差)来筛选图片

针对这一场景,设想了以下的几种方案,一一来进行说明

数据库单列存储标签数组字符串

增加表t_tags存储所有标签,内容如:

1
2
3
4
5
1  动漫
2 日漫
...
1000 机器猫
.....

在存储图片内容的表中增加一列tags,存储标签ID列表(使用逗号分割),如:1,2,1000

如此在查询图片内容时可以同时带出标签列表(对tags列分割为多行(利用substring_index)后join;或者查询出tags内容反序列化后再查t_tags表);但是:

  • 难以使用标签搜索图片,like “%tagid%”的方式比较低效;
  • 修改图片的标签列表时,必须对tags列反序列化修改后再序列化存储;
  • 交、并、差等操作难以进行

关联表存储图片-标签多对多关系

图片和标签是一个多对多的关系,处理多对多的情况那么学院派的做法可以引入一个关联表t_img_tags,使用以下sql创建示例表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
-- 图片存储表
CREATE TABLE `t_imgs` (
`id` bigint(20) NOT NULL,
`img_name` char(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='图片存储表';

INSERT INTO `t_imgs` (`id`, `img_name`) VALUES (1, 'a.png');
INSERT INTO `t_imgs` (`id`, `img_name`) VALUES (2, 'b.jpg');
INSERT INTO `t_imgs` (`id`, `img_name`) VALUES (3, 'c.jpeg');

-- 标签记录表
CREATE TABLE `t_tags` (
`id` bigint(20) NOT NULL,
`tag` varchar(32) COLLATE utf8mb4_bin NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `tag` (`tag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='标签记录表';

INSERT INTO `t_tags` (`id`, `tag`) VALUES (1, '动漫');
INSERT INTO `t_tags` (`id`, `tag`) VALUES (2, '日漫');
INSERT INTO `t_tags` (`id`, `tag`) VALUES (1001, '机器猫');

-- 图片-标签关联表
CREATE TABLE `t_img_tag` (
`img_id` bigint(20) NOT NULL,
`tag_id` bigint(20) NOT NULL,
PRIMARY KEY (`img_id`,`tag_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='图片-标签关联表';

INSERT INTO `t_img_tag` (`img_id`, `tag_id`) VALUES (1, 1001);
INSERT INTO `t_img_tag` (`img_id`, `tag_id`) VALUES (1, 2);
INSERT INTO `t_img_tag` (`img_id`, `tag_id`) VALUES (2, 1);
INSERT INTO `t_img_tag` (`img_id`, `tag_id`) VALUES (3, 1001);

ALTER TABLE `test`.`t_img_tag`
ADD FOREIGN KEY (`img_id`) REFERENCES `test`.`t_imgs` (`id`),
ADD FOREIGN KEY (`tag_id`) REFERENCES `test`.`t_tags` (`id`);

此时查询图片的标签可以直接通过关联表join,或者分多步查询来取得最终结果;根据单个标签筛选图片时也是如此

多个标签AND时可以通过关联表的join进行

1
2
3
4
5
6
-- 查询同时存在id为2、1001的标签的所有图片的id
SELECT
t1.img_id
FROM
( SELECT * FROM t_img_tag WHERE tag_id = 2 ) t1
INNER JOIN ( SELECT * FROM t_img_tag WHERE tag_id = 1001 ) t2 ON t1.img_id = t2.img_id

标签的差集也是类似的思路:

1
2
3
4
5
6
7
8
-- 查询存在标签1、不存在标签1001的所有图片的id
SELECT DISTINCT
t1.img_id
FROM
t_img_tag t1
INNER JOIN ( SELECT * FROM t_img_tag WHERE img_id NOT IN ( SELECT img_id FROM t_img_tag WHERE tag_id = 1001 ) ) t2 ON t1.img_id = t2.img_id
WHERE
t1.tag_id = 1

引入关联表后,对标签的更新、查询(交、差集等)的问题得到一定的解决;当然,考虑到绝大多数查询会是查询图片的标签,那么也可以在t_imgs表保留tags字段,以图片更新标签/标签文本更新时的额外操作来换取查询时的便捷

这种方式其实足以应对功能需求,但是在数据量大时表现如何?联合查询的情况下索引效率如何?恐怕很难说

nosql存储图片-标签关系

以文档型nosql mongodb为例,可以在img的document中添加一个array类型的字段来存储图片关联的标签,在这一个array字段建立索引以优化查询

示例后补

引入mongo需要考虑以下问题。在我们的系统中,图片存储记录属于重要度高的数据,因其敏感性,不可能只依赖mongo存储(事务、数据可靠性的保障),因此考虑图片及标签使用mysql存储,在标签更新时同步更新mongo记录,通过标签查询时使用mongo;如果可以接受数据延迟的情况,也可以通过MQ等异步处理,或者监听mysql binlog异步写入,甚至是通过离线任务定时同步数据。

在此之上就不得不继续考虑引入mongo的必要性:是否有必要引入mongo?引入一个中间件带来的运维部署成本,对比引入mongo的需求,是否有绝对的必要呢?

考虑mongo的优势,后续如果涉及图片、标签数据的分析处理,或者可能涉及到mongo擅长的gis地理位置信息,那么引入mongo也无可厚非;如果还可能涉及到大量数据的检索,那么可以考虑ES

bitmap

bitmap适合于高效标记大量数据的有无,并且可高效的进行交、并、补集运算,这简直是为bitmap量身定制的需求呀:

  • 为每个标签维护一个bitmap,记录图片的标签使用状态
  • 假设有百万规模的图片,每个标签为了记录所有图片的关联状态,需要有百万bit的空间,按照 2^20 bit = 1048576 bit 计算,每个标签占用128KB
  • 在某些标签关联的图片很少即数据稀疏时,是不是浪费了部分内存?使用bitmap与布隆过滤器介绍的RoaringBitmap等实现,可以在数据稀疏时减少内存浪费。如果量真的达到了不得不治理的程度(标签很多),相信进行数据分析、找出“僵尸标签”的成本也不难接受了
  • 考虑到实际系统中id通常使用雪花ID,为了高效利用bitmap,可以将img_id减去固定的初始偏移值后再存储到bitmap,在读/写时进行处理(可以封装bitmap类,类似装饰类的思路)
  • bitmap本身就极适于集合的交、并、差等计算,用作多标签的交、并、差的筛选很适合

接下来是另一个问题:bitmap的数据如何存储?redis支持bitmap(setbit、getbit、bitcount、bitop等指令),但是如何持久化呢?

思考如何持久化,首先得想清楚在mysql中如何存储bitmap。在mysql 8中测试,mysql支持bit类型,但是最大长度为64;binary类型存储二进制数据,但是最大长度为255;varbinary是binary的可变长类型,最大程度可以达到mysql的单行最大长度65535(有其他列时需减去其他列占用的长度),与期望的能容纳百万bit差距甚远;

因此,只能考虑blob/text类型,但text类型作为文本类型,即使选用ASCII编码,单个’1’也将被视为字符’1’占用一字节。因此只能考虑blob类型,blob类型最大可达到64KB,MEDIUMBLOB类型可以达到16MB,而更大的LONGBLOB可以达到4GB。当然实际的单次能操作的数据量,还需要考虑网络包大小、mysql支持的客户端传输的单个包体积限制等

为了足够存储2^20规模图片的状态,需要128KB的空间,我们可以使用MEDIUMBLOB;当然,也可以联用两个blob的列来拼出128KB的空间,但是如此将不能直接在这一列上执行二进制操作

这里使用mediumblob类型进行演示:

1
2
3
4
5
6
7
8
CREATE TABLE `t_label_bitmap` (
`label_id` bigint(20) NOT NULL,
`img_bitmap` mediumblob,
PRIMARY KEY (`label_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

-- demo数据
INSERT INTO `t_label_bitmap` (`label_id`, `img_bitmap`) VALUES (1, NULL);

使用jdk bitset + JDBC原生API操作示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 1048576 = 2^20
BitSet bitSet = new BitSet(1048576);
bitSet.set(12);
bitSet.set(12344);

System.out.println(bitSet.get(12));
System.out.println(bitSet.get(12344));
System.out.println(bitSet.toString());

Connection connection = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/test?useSSL=false&useUnicode=true&characterEncoding=UTF-8",
"root", "root.123");
PreparedStatement statement = connection.prepareStatement(
"update t_label_bitmap set img_bitmap = ? where label_id = ?");
statement.setBytes(1, bitSet.toByteArray());
statement.setLong(2, 1);
boolean b = statement.execute();

statement.clearParameters();
System.out.println();

statement = connection.prepareStatement("select label_id,img_bitmap from t_label_bitmap where label_id = ?");
statement.setLong(1, 1);
ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
long id = resultSet.getLong(1);
byte[] bitmap = resultSet.getBytes(2);
bitSet = BitSet.valueOf(bitmap);
}
System.out.println(bitSet.get(12));
System.out.println(bitSet.get(12344));
System.out.println(bitSet.toString());

resultSet.close();
statement.close();
connection.close();

在执行update后可以看到,img_bitmap列存储了1.51KB的数据(因为bitset的实际空间是按需分配的),同样的可以根据byte数组还原bitset

也可以使用spring-data-jdbc提供的JdbcTemplate类完成同样的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 1048576 = 2^20
BitSet bitSet = new BitSet(1048576);
bitSet.set(12);
bitSet.set(12344);

System.out.println(bitSet.get(12));
System.out.println(bitSet.get(12344));
System.out.println(bitSet.toString());

BitSet finalBitSet = bitSet;
jdbcTemplate.update("update t_label_bitmap set img_bitmap = ? where label_id = ?",
new PreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps) throws SQLException {
ps.setBytes(1, finalBitSet.toByteArray());
ps.setLong(2, 1);
}
});

System.out.println();

bitSet = jdbcTemplate.query("select label_id,img_bitmap from t_label_bitmap where label_id = ?",
new PreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps) throws SQLException {
ps.setLong(1, 1);
}
},
new ResultSetExtractor<BitSet>() {
@Override
public BitSet extractData(ResultSet rs) throws SQLException, DataAccessException {
while (rs.next()) {
return BitSet.valueOf(rs.getBytes(2));
}
return null;
}
});

System.out.println(bitSet.get(12));
System.out.println(bitSet.get(12344));
System.out.println(bitSet.toString());

bitmap如何持久化

以上的实例中我们解决了bitmap持久化的读写问题,但是在实际业务中怎样来做持久化操作是最合适的呢?在读写效率和数据可靠性上如何选择呢?

直接使用mysql存储bitmap,不引入redis,可以实现但是效率堪忧;使用redis作为mysql的缓存,查询可以得到加速,但是在频繁更新的时候需要大量的序列化、反序列化以及写大对象的操作。因此,如果标签数据不是很敏感,更推荐redis bitmap + 定时持久化到mysql的方式;或者,能够接受有人del key 或 flush 操作后操作数据恢复的话,完全依赖redis的持久化机制也并非不可行