前言

近日CTO让我排查下最近数据库费用为什么增长的这么快,我们数据库使用了阿里云的按量付费,费用增长快肯定是数据空间增长的非常快,于是我统计了一下所有表的物理空间大小,然后发现了一张数据库刺客,这张表的空间占据了整个数据库容量的40%左右,表里主要存了一个大JSON字段,平均行大小超过了40KB。问题找到了,那么该怎么解决这个问题呢?

「数据特点」

首先我向产品了解这张表的数据特点:

  1. 不能删除,永久保留
  2. 只会插入,不会修改
  3. 高频写入,低频查询

然后我阅读了这张表的dao层代码,并梳理了一下相关代码的特点:

「代码特点」

  1. 底层服务,调用业务众多
  2. 功能简单,单行插入和ID查询

改造过程

「思考过程」

面对这个问题我做了如下思考:

  1. 写代码人已走,不要抱怨前人
  2. 能否换数据库?换什么数据库?
  3. 有没有必要存数据库?

第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,那么撸代码去解决这个问题思路是这样的:

  1. 增加一个拦截器,拦截数据库Insert和Select命令
  2. 在Insert时,获取对象JSON内容写入NAS,将文件路径写数据库
  3. 在Select时,获取NAS文件路径,读取文件内容后返回到对象中
  4. 添加表注解和字段注解,只对指定的字段生效

「添加注解」

添加表注解

@Target(ElementType.TYPE)
   @Retention(RetentionPolicy.RUNTIME)
   @Documented
   public @interface EnableCustomInterceptor {

   }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

添加字段注解

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SyncToDisk {
    String rootPath();
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

「添加拦截器」

添加Mybatis拦截器,拦截所有插入和查询请求,在拦截器中对插入和查询进行数据存NAS和读NAS

@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内容更新到文件中
        }

    }


}
  • 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.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.

测试

「注入拦截器」

将自定义的Mybatis拦截器注入Spring容器中

@Bean
 public Interceptor SyncToDiskInterceptor() {
   return new SyncToDiskInterceptor();
}
  • 1.
  • 2.
  • 3.
  • 4.

「实体增加注解」

将我们要同步数据库实体对象字段增加注解

@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;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

「测试用例」

插入

@Test
public void testInsert() {
    User user =new User();
    user.setUserInfo("赵侠客1");
    user.setUserConfig("赵侠客公众号1");    userMapper.insert(user);
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

通过ID查询

@Test
public void testQuery() {
    User user = userMapper.selectById(1L);
    Assertions.assertEquals("赵侠客",user.getUserInfo());
    Assertions.assertEquals("赵侠客公众号",user.getUserConfig());
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

批量查询

@Test
public void testQueryList() {
    List<User> users = userMapper.selectList(new QueryWrapper<>());
    users.stream().forEach(x->{
        log.info("user {}->{}",x.getId(),x.getUserConfig());
    });
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

更新

@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());
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

总结

本方法完全是自己脑洞大开想出来,感觉路子有点野,我在市面上没见过类似的来源项目,不知道市面上有没有类似的需求,大家可以评论区讨论下,总结本方法的特点

「使用场景」

  1. 数据库存在大字段,空间非常大,增长比较快,为了降低存储成本
  2. 不需要严格事务支持,没有频繁更新操作
  3. 非常适用于日志记录类业务场景
  4. 高频查询可以存OSS的URL,使用方通过URL走CDN解决高频读问题

「方案优点」

  1. 降本增效:存储成本降低93%,CTO肯定给你年终奖翻倍
  2. 代码改动小:只需要增加两个注解,原有业务逻辑不需要改动
  3. 复用性强:其它项目如有类似需求,引入依赖,开箱即用
  4. 存储选择多:在不想换数据库的情况下,可以将大字段存NAS,OSS,七牛等更便宜的存储

「方案缺点」

  1. 未能解决事务问题:如果数据库插入事务回滚,NAS文件不会删除
  2. 不适合有复杂查询业务场景:代码未经严格测试,不知道其它复杂场景会不会有问题
  3. 更新数据不太优雅:更新数据时需要从数据库通过ID查出文件位置再更新,不能通过其它字段更新数据

「未来展望」

  1. 做成SpringBoot-Start自动配置装配,方便使用
  2. 支持删除操作,数据库删除后NAS文件同步删除
  3. 支持异步写文件,增加写文件吞吐量
  4. 支持插入事务回滚时删除文件
  5. 支持储存可配置,如NAS,OSS,七牛等多种选择

最后如果穷厂多可以做成开源项目,名字就叫 「MyBatis-Poor」 ,不穷的厂也不会在乎数据库这点存储费用,数据库配置直接拉满,大JSON直接存就完。