在之前特意穿插了一篇介绍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
| 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
| 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;
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
| 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
| 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的持久化机制也并非不可行