原文:赵侠客
前言
近日CTO让我排查下最近数据库费用为什么增长的这么快,我们数据库使用了阿里云的按量付费,费用增长快肯定是数据空间增长的非常快,于是我统计了一下所有表的物理空间大小,然后发现了一张数据库刺客 ,这张表的空间占据了整个数据库容量的40%左右,表里主要存了一个大JSON字段,平均行大小超过了40KB。问题找到了,那么该怎么解决这个问题呢?
「数据特点」
首先我向产品了解这张表的数据特点:
- 不能删除,永久保留
- 只会插入,不会修改
- 高频写入,低频查询
然后我阅读了这张表的dao层代码,并梳理了一下相关代码的特点:
「代码特点」
- 底层服务,调用业务众多
- 功能简单,单行插入和ID查询
改造过程
「思考过程」
面对这个问题我做了如下思考:
- 写代码人已走,不要抱怨前人
- 能否换数据库?换什么数据库?
- 有没有必要存数据库?
第2点换数据库肯定是可以的,而且这中业务场景MongDB这种文档型数据库再适合不过了,不过MondBD也是比较贵的。第3点这种不变数据好像不放数据库也可以,和静态化文件业务场景类似,难道不能存OSS、NAS吗?考虑到OSS的特点是写慢查快,NAS写入比OSS延时短,而且NAS还有各种存储类型可以选则,如容量型、性能型、归档型、极速型等,我们这个业务是写多读少,所以最后选择的方案是将这部分数据存NAS,那么该如何存呢?
「踩坑过程」
本想着通过阿里云PolarDB分区表来将数据按月分区,然后将老的分区数据自动归档到OSS,成本可以降低97%,也不用改动业务代码,可是阿里云这个功能还是Beta版本,用了一下问题比较多,还帮他们发现了一个BUG,一个分区语句居然将数据库所有表对应的数据库搞乱了,所有业务都报表不存在,数据库里表是存在的,后来他们说是Proxy出了问题,还好是在测试环境,要是生产环境就直接走人了,感觉这个功能不太成熟,还是安全第一,自己撸代码角度解决吧。
阿里云
PolarDB 4.3万/TB/年
MongDB 3.4万/TB/年
OSS 0.1万/TB/年
NAS 0.3万/TB/年
代码编写
「代码思路」
我们DAO层用的是Mybatis,那么撸代码去解决这个问题思路是这样的:
- 增加一个拦截器,拦截数据库Insert和Select命令
- 在Insert时,获取对象JSON内容写入NAS,将文件路径写数据库
- 在Select时,获取NAS文件路径,读取文件内容后返回到对象中
- 添加表注解和字段注解,只对指定的字段生效
「添加注解」
添加表注解
less
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface EnableCustomInterceptor {
}
添加字段注解
java
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SyncToDisk {
String rootPath();
}
「添加拦截器」
添加Mybatis拦截器,拦截所有插入和查询请求,在拦截器中对插入和查询进行数据存NAS和读NAS
scss
@Intercepts(
{
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
}
)
public class SyncToDiskInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
// 获取Executor对象
Executor executor = (Executor) invocation.getTarget();
// 从Executor获取Connection
Connection connection = executor.getTransaction().getConnection();
MappedStatement mappedStatement = (MappedStatement) args[0];
final Class<?> entityClass = getMapperGenericClass(mappedStatement);
if (entityClass.getAnnotation(EnableCustomInterceptor.class) != null) {
if (args.length == 4) {
Object result = invocation.proceed();
traverseParam(mappedStatement.getSqlCommandType(), result, entityClass, connection);
return result;
} else {
Object sqlParams = args[1];
traverseParam(mappedStatement.getSqlCommandType(), sqlParams, entityClass, connection);
}
}
return invocation.proceed();
}
private void traverseParam(SqlCommandType sqlCommandType, Object param, Class<?> entityClass, Connection connection ) {
if (param == null) {
return;
}
if (param.getClass().getAnnotation(EnableCustomInterceptor.class) != null) {
interceptField(sqlCommandType, param, entityClass,connection);
} else if (param instanceof Map) {
final Map<?, ?> map = (Map<?, ?>) param;
for (Object value : map.values()) {
traverseParam(sqlCommandType, value, entityClass, connection);
}
} else if (param instanceof Collection) {
final Collection<?> collection = (Collection<?>) param;
for (Object item : collection) {
traverseParam(sqlCommandType, item, entityClass, connection);
}
}
}
private void interceptField(SqlCommandType sqlCommandType, Object param, Class<?> entityClass, Connection connection) {
//处理查询 完整代码可私信获取
if (SqlCommandType.SELECT.equals(sqlCommandType)) {
//TODO 获取数据中的文件路径,然后取取文件内容,存入对象
}
//处理插入
if (SqlCommandType.INSERT.equals(sqlCommandType)) {
//TODO 获取大JSON文件内容,写入数据库,然后将文件路径存入数据库
}
//处理更新
if (SqlCommandType.UPDATE.equals(sqlCommandType)) {
//TODO 查询数据库原有文件路径,将大JSON内容更新到文件中
}
}
}
测试
「注入拦截器」
将自定义的Mybatis拦截器注入Spring容器中
java
@Bean
public Interceptor SyncToDiskInterceptor() {
return new SyncToDiskInterceptor();
}
「实体增加注解」
将我们要同步数据库实体对象字段增加注解
java
@Data
@TableName("user_oss")
@EnableCustomInterceptor
public class User {
@TableId
private Long id;
@SyncToDisk(rootPath = "/tmp/saveToDisk")
private String userConfig;
@SyncToDisk(rootPath = "/tmp/saveToDisk")
private String userInfo;
}
「测试用例」
插入
java
@Test
public void testInsert() {
User user =new User();
user.setUserInfo("赵侠客1");
user.setUserConfig("赵侠客公众号1"); userMapper.insert(user);
}
通过ID查询
java
@Test
public void testQuery() {
User user = userMapper.selectById(1L);
Assertions.assertEquals("赵侠客",user.getUserInfo());
Assertions.assertEquals("赵侠客公众号",user.getUserConfig());
}
批量查询
java
@Test
public void testQueryList() {
List<User> users = userMapper.selectList(new QueryWrapper<>());
users.stream().forEach(x->{
log.info("user {}->{}",x.getId(),x.getUserConfig());
});
}
更新
java
@Test
public void testUpdate() {
String newTrueName=String.valueOf(System.currentTimeMillis());
User user = userMapper.selectById(1L);
user.setUserInfo(newTrueName);
userMapper.updateById(user);
User user1 = userMapper.selectById(1L);
Assertions.assertEquals(newTrueName,user1.getUserInfo());
}
总结
本方法完全是自己脑洞大开想出来,感觉路子有点野,我在市面上没见过类似的来源项目,不知道市面上有没有类似的需求,大家可以评论区讨论下,总结本方法的特点
「使用场景」
-
数据库存在大字段,空间非常大,增长比较快,为了降低存储成本
-
不需要严格事务支持,没有频繁更新操作
-
非常适用于日志记录类业务场景
-
高频查询可以存OSS的URL,使用方通过URL走CDN解决高频读问题
「方案优点」
- 降本增效:存储成本降低93%,CTO肯定给你年终奖翻倍
- 代码改动小:只需要增加两个注解,原有业务逻辑不需要改动
- 复用性强:其它项目如有类似需求,引入依赖,开箱即用
- 存储选择多:在不想换数据库的情况下,可以将大字段存NAS,OSS,七牛等更便宜的存储
「方案缺点」
- 未能解决事务问题:如果数据库插入事务回滚,NAS文件不会删除
- 不适合有复杂查询业务场景:代码未经严格测试,不知道其它复杂场景会不会有问题
- 更新数据不太优雅:更新数据时需要从数据库通过ID查出文件位置再更新,不能通过其它字段更新数据
「未来展望」
- 做成SpringBoot-Start自动配置装配,方便使用
- 支持删除操作,数据库删除后NAS文件同步删除
- 支持异步写文件,增加写文件吞吐量
- 支持插入事务回滚时删除文件
- 支持储存可配置,如NAS,OSS,七牛等多种选择
最后如果穷厂 多可以做成开源项目,名字就叫 「MyBatis-Poor」 ,不穷的厂也不会在乎数据库这点存储费用,数据库配置直接拉满,大JSON直接存就完。