cover_image

拍小租账单拆分方案:破解大数据与高性能查询的双重挑战

督邮 拍码场
2023年11月22日 02:30

前言

随着公司两轮电动车租赁业务拍小租的快速发展,系统的数据量高速增长,尤其是账单表部分,目前已经达到了近亿级别。由于账单表包含大量的慢SQL查询,这给系统性能带来了巨大的压力。为了在不影响业务运行的前提下预先解决潜在的系统性能风险,我们经过深思熟虑,决定采取拆表的操作。

一般来说,当单表的数据量达到1000万条或者数据容量超过5G的限制时,我们就需要考虑进行分库分表的操作。因为在这种情况下,单表的数据量已经超出了数据库的承受能力,继续使用单表会导致数据库性能的严重下降。而拍小租的业务特性决定了我们需要根据不同的维度来查询账单列表。因此,即使我们将账单表进行水平拆分,也需要确保能够满足业务在不同维度上的查询需求。

思考

为了实现这一目标,我们采取了以下的关键步骤。

首先,我们进行了详细的业务需求分析,了解了业务对账单查询的各种需求和期望。这样做的目的是为了确保我们在拆表过程中不会破坏原有的查询功能。其次,我们对现有的数据库架构进行了深入的研究和分析,了解了其优缺点以及潜在的性能风险。

接着,我们根据业务需求和数据库架构的特点,制定了一套科学合理的拆表方案。该方案不仅考虑了数据量的控制,还考虑了查询性能的优化以及业务查询需求的满足。

最后,我们在实际操作中严格按照方案执行,并对拆表后的系统进行了严格的测试,确保拆表操作的成功以及系统的稳定运行。

分表方案

我们的对客展示由四个关键组成部分构成:

  1. 商户管理后台
  2. 用户小程序
  3. 商户小程序
  4. 运营管理后台

为了满足每个产品的特定需求,我们采取了一种精细的拆分策略来存储和管理账单数据。

  • 商户管理后台商户小程序主要服务于商户,它们需要根据商户编号和其他关键信息查询账单列表。为了高效地满足这些需求,我们将数据存储在多个表中,根据不同需求进行拆分。这样,当需要查询账单列表时,我们可以迅速地从相应的表中获取数据。

  • 用户小程序则主要面向用户,它需要根据用户ID来查询账单列表。考虑到用户数量可能非常庞大,我们同样根据用户ID进行了表的拆分,以便在处理大量数据时保持良好的性能。

  • 运营管理后台需要根据商户编号或用户ID以及其他关键账单信息查询账单列表。为了满足这一复杂需求,我们采取了一种混合的拆分策略,将数据分散存储在多个表中。

在处理账单数据的增删改查操作时,要保证数据一致性。因此,总表和多个维度的分表都会放在同一个事务中,以确保数据的准确性和完整性。

拆分过程中,我们采用了两种方案:一种是基于某个字段进行取模分表,另一种是基于日期进行分表。这种灵活的分表策略使得我们能够根据实际情况调整数据存储方案,更好地满足不同产品的需求。

总的来说,我们能够高效地处理大量账单数据,并提供灵活的数据查询和操作功能。通过独特的拆分策略和事务管理机制,我们确保了数据的一致性和完整性,同时也提高了系统的性能和响应速度。

取模分表

1.用户ID取模分表(全部字段)

满足用户小程序运营管理后台的需求。

图片

单表上限(1000万)条数据,根据某个字段(用户ID)取模拆分为(64)个表,tb_账单_用户ID_00~tb_账单_用户ID_63。

  • 示例1:用户ID=100288, 取模为 0;则存入表tb_账单_用户ID_00。

  • 示例2:用户ID=100300, 取模为 12;则存入表tb_账单_用户ID_12。

  • 示例3:用户ID=100351, 取模为 63;则存入表tb_账单_用户ID_63。

2.商户编号取模分表(全部字段)

满足商户管理后台商户小程序运营管理后台的需求

图片

单表上限(1000万)条数据,根据某个字段(商户编号)取模拆分为(64)个表,tb_账单_商户编号_00~tb_账单_商户编号_63。

  • 示例1:商户编号=SH100288, 取模为 0;则存入表tb_账单_商户编号_00。

  • 示例2:商户编号=SH100300, 取模为12;则存入表tb_账单_商户编号_12。

  • 示例3:商户编号=SH100351, 取模为 63;则存入表tb_账单_商户编号_63。

3.账单编号映射分表(只有账单编号和用户ID)

这个维度的分表是解决用户小程序的需求。

图片

单表上限(1000万)条数据,根据某个字段(账单编号)取模拆分为(32)个表,tb_账单_账单编号_用户编号_00~tb_账单_账单编号_用户编号_31。

  • 示例1:账单编号=100288, 取模为 0;则存入表tb_账单_账单编号_用户编号_00。

  • 示例2:账单编号=100300, 取模为 12;则存入表tb_账单_账单编号_用户编号_12。

  • 示例3:账单编号=100351, 取模为 31;则存入表tb_账单_账单编号_用户编号_31。

4.订单编号映射分表(只有订单编号和用户ID)

满足了所有根据订单查询账单列表的需求。

图片

单表上限(1000万)条数据,根据某个字段(订单编号)取模拆分为(32)个表,tb_订单编号_用户ID_00~tb_订单编号_用户ID_31。

  • 示例1:订单编号=100288, 取模为 0;则存入表tb_订单编号_用户ID_00。

  • 示例2:订单编号=100300, 取模为 12;则存入表tb_订单编号_用户ID_12。

  • 示例3:订单编号=100351, 取模为 31;则存入表tb_订单编号_用户ID_31。

日期分表

1.到期日分表(全部字段)

系统内部会根据账单的到期日进行代扣和各种短信提醒等等功能,都要使用这个到期日字段。

图片

单表上限(500万)条数据,根据某个字段(到期日),(4)个月为1张表,tb_账单_到期日_00~tb_账单_到期日_...。

图片

  • 示例1:到期日=20201229,属于范围[20201201,20210331],存入表tb_账单_到期日_00。

  • 示例2:到期日=20230803,属于范围[20230801,20231130],存入表tb_账单_到期日_08。

  • 示例3:到期日=20401229,属于范围[20401201,20410331],存入表tb_账单_到期日_63。

2.插入日期分表(全部字段)

这个分表是满足运营管理后台需求和方便开发测试人员查看数据。分表逻辑同到期日。

  • 示例1:插入时间=20201229,属于范围[20201201,20210331],存入表tb_账单_插入时间_00。

  • 示例2:插入时间=20230803,属于范围[20230801,20231130],存入表tb_账单_插入时间_08。

  • 示例3:插入时间=20401229,属于范围[20401201,20410331],存入表tb_账单_插入时间_63。

注意事项

在数据库设计中,通常会根据业务需求和数据特点进行分表。有时,我们会使用固定值的字段进行分表,例如根据日期或者客户ID等。然而,有些特殊情况下,产品的需求会发生改变,导致原本固定值的字段变得可变。这时候,就需要对原有的分表进行特殊处理。

以一个例子来说明这种情况,比如我们根据到期日来分表。在正常情况下,每个账单的到期日是固定的,所以我们可以通过到期日来区分不同的账单数据。然而,如果业务需求允许商户修改账单的到期日,这就意味着我们不能再简单地通过到期日来区分不同的账单数据了。这时候,我们就需要对原有的分表数据进行重新处理。在确定需要重新分表之后,我们需要将原有分表中的数据迁移到新的分表中。这可以通过编写脚本或者使用数据库管理工具来完成。

总之,当产品需求的特殊性导致原本固定值的字段可变时,需要对原有分表进行特殊处理。这需要我们仔细分析业务需求和数据特点,选择合适的分表方案,并进行测试和调整应用程序代码。

分布式ID

目前流行的分布式ID生成方案有9种,这些方案各有优劣,但综合考虑到实际应用场景和系统性能,我们最终选择了基于数据库的号段模式+号段内redis的list数据结构的ID生成方案。这种方案具有高可用性、高性能、可扩展性等优点,同时也能够保证ID的唯一性和有序性。

图片

生产服务是以集群的形式进行部署的,因此在发布时,部分服务会采用数据库的ID自增方式,而另一些服务则会使用Redis的号段ID自增。由于这两种自增方式有可能会产生ID重复的问题,因此在发布前置版本时,我们需要在数据库ID自增的同时,触发Redis的自增ID弹出。这样能够确保数据库ID自增和Redis自增保持同步,避免ID重复。

在这个过程中,生成的数据ID有可能会跳段,但这是为了确保不会出现重复的情况。在全部使用前置分支发布完成后,我们需要观察一段时间,确保一切都正常运行。只有在确认没有问题后,我们才会发布最终的分表分支。

数据同步方案

  1. 对于新增账单,我们采取了双写的策略。也就是说,当账单新增时,我们不仅将数据存入未拆分的总表中,还会将相同的数据存入拆分之后的表中。这样做的目的是为了确保数据的完整性和一致性,同时也能让业务系统在处理账单时不受拆分的影响,保持原有的流程和逻辑。

  2. 为了实现这个目标,我们需要对修改分表数据的方法进行try-catch处理,这样即使出现异常或错误,我们也能捕获到错误信息,并打印出详细的日志,以便进行观察和分析。这样的处理方式能够保证我们在进行账单拆分的过程中,不会对业务系统产生任何负面影响。

  3. 对于历史账单数据,我们采取了接口的形式,将总表中的全量数据按照拆分逻辑逐一存入到分表中。这个过程需要我们对数据进行逐一比对和处理,以确保数据的准确性和一致性。同时,我们还会对整个过程进行严格的监控和管理,确保数据的迁移和拆分过程不会对业务系统产生影响。

  4. 为了验证账单拆分的效果和准确性,我们会进行人工抽样检查。同时,我们还会对全量数据进行对比,以确保数据的总数量和分表总数量保持一致。

  5. 在进行账单拆分的过程中,我们需要逐步将查询的逻辑根据功能维度进行拆分。这个过程可能需要花费几个月甚至超过1年的时间来完成。我们会通过一段时间的观察和实践,确认数据完全一致后,再将查询的逻辑根据功能维度拆分到查询分表中。这样的处理方式能够保证我们在进行账单查询时,能够快速、准确地获取到所需的数据。

  6. 当全部功能都成功拆分到查询分表后,我们还需要继续观察几个月,确保没有问题后,我们就可以将总表下线,完成最终的拆分方案。这样就能够保证我们的账单数据在保持原有功能的前提下,实现了拆分和迁移的目标。

关键代码

  1. 分布式自增ID
private  void createId(int bizType, int step) {
int updateMaxId = 0;
long minId = 0;
do {
IdGenerator lastByBizType = notNullLastByBizType(bizType);
minId = lastByBizType.getMaxId() + 1;
updateMaxId = baseMapper.updateMaxId(step, lastByBizType.getIdVersion(), bizType);
} while (updateMaxId == 0);
String key = RedisPrefixUtils.ID_GENERATOR + bizType;
redisTemplate.delete(key);
// 创建成功,缓存list到redis
List<Long> collect = Stream.iterate(minId, item -> item + 1).limit(step).collect(Collectors.toList());
// leftPushAll直接存储ArrayList是有问题的,必须转换为数组collect.toArray()
redisTemplate.opsForList().leftPushAll(key, collect.toArray());
}

@Override
public synchronized long getNextId(int bizType) {
Object rightPop = redisTemplate.opsForList().rightPop(RedisPrefixUtils.ID_GENERATOR + bizType);
if (!Objects.isNull(rightPop)) {
return Long.valueOf(rightPop.toString());
}
// 空的就去申请新的号段
createId(bizType, IdBizTypeEnum.getStep(bizType));
rightPop = redisTemplate.opsForList().rightPop(RedisPrefixUtils.ID_GENERATOR + bizType);
return Long.valueOf(rightPop.toString());
}
  1. mybatisPlus分表
@Configuration
public class MybatisPlusConfig {

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor = new DynamicTableNameInnerInterceptor();
Map<String, TableNameHandler> tableNameHandlerMap = new HashMap<>();
tableNameHandlerMap.put(BillItemData.TB_账单_账单编号_用户ID, BillItemData.TB_账单_账单编号_用户ID_HANDLER);
tableNameHandlerMap.put(BillItemData.TB_订单编号_用户ID, BillItemData.TB_订单编号_用户ID_HANDLER);
tableNameHandlerMap.put(BillItemData.TB_账单_用户ID, BillItemData.TB_账单_用户ID_HANDLER);
tableNameHandlerMap.put(BillItemData.TB_账单_商户编号, BillItemData.TB_账单_商户编号_HANDLER);
tableNameHandlerMap.put(BillItemData.TB_账单_到期日, BillItemData.TB_账单_到期日_HANDLER);
tableNameHandlerMap.put(BillItemData.TB_账单_插入日期, BillItemData.TB_账单_插入日期_HANDLER);
dynamicTableNameInnerInterceptor.setTableNameHandlerMap(tableNameHandlerMap);
interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
PaginationInnerInterceptor page = new PaginationInnerInterceptor();
page.setDialect(new MySqlDialect());
interceptor.addInnerInterceptor(page);
return interceptor;
}
}
/**
* 账单分表缓存类
*/

public class BillItemData {

public static final String TB_账单_账单ID_用户ID = "TB_账单_账单ID_用户ID";
public static final String TB_订单编号_用户ID = "TB_订单编号_用户ID";
public static final String TB_账单_用户ID = "TB_账单_用户ID";
public static final String TB_账单_商户编号 = "TB_账单_商户编号";
public static final String TB_账单_到期日 = "TB_账单_到期日";
public static final String TB_账单_插入日期 = "TB_账单_插入日期";

public static final String START_STR = "20180101";
public static final LocalDate START_DUE_DATE = LocalDate.parse(START_STR, DateUtils.DF_YYYYMMDD);
public static final LocalDate START_INSERT_DATE = LocalDate.parse(START_STR, DateUtils.DF_YYYYMMDD);

/**
* 账单编号和用户ID映射关系
*/

public static final TableNameHandler TB_账单_账单ID_用户ID_HANDLER =
(sql, tableName) -> SplitTableUtils.getRemainderSuffixTableName(tableName,
String.valueOf(BillItemRequestDataHelper.get账单Id()), SplitTableUtils.SPLIT_NUM_32);

/**
* 订单单编号和用户ID映射关系
*/

public static final TableNameHandler TB_订单编号_用户ID_HANDLER =
(sql, tableName) -> SplitTableUtils.getRemainderSuffixTableName(tableName,
String.valueOf(BillItemRequestDataHelper.get订单Id()), SplitTableUtils.SPLIT_NUM_32);

/**
* 用户ID拆分
*/

public static final TableNameHandler TB_账单_用户ID_HANDLER =
(sql, tableName) -> SplitTableUtils.getRemainderSuffixTableName(tableName,
String.valueOf(BillItemRequestDataHelper.get用户Id()), SplitTableUtils.SPLIT_NUM_64);
/**
* 商户编号拆分
*/

public static final TableNameHandler TB_账单_商户编号_HANDLER =
(sql, tableName) -> SplitTableUtils.getRemainderSuffixTableName(tableName,
String.valueOf(BillItemRequestDataHelper.get商户编号()), SplitTableUtils.SPLIT_NUM_64);
/**
* 到期日拆分
*/

public static final TableNameHandler TB_账单_到期日_HANDLER =
(sql, tableName) -> SplitTableUtils.getDateSuffixTableName(tableName, START_DUE_DATE,
BillItemRequestDataHelper.get到期日(), SplitTableUtils.SPLIT_MONTH);
/**
* 插入日期拆分
*/

public static final TableNameHandler TB_账单_插入日期_HANDLER =
(sql, tableName) -> SplitTableUtils.getDateSuffixTableName(tableName, START_INSERT_DATE,
BillItemRequestDataHelper.get插入日期(), SplitTableUtils.SPLIT_MONTH);

}
public class SplitTableUtils {

public static final int SPLIT_MONTH = 4;

public static final int SPLIT_NUM_32 = 32;
public static final int SPLIT_NUM_64 = 64;

/**
* 求余分表
* @param tableName
* @param splitStr
* @param splitNum
* @return
*/

public static String getRemainderSuffixTableName(String tableName, String splitStr, int splitNum) {
int hashValue = hash(splitStr);
int suffix = hashValue & (splitNum - 1);
if (suffix < 10) {
return tableName + "_0" + suffix;
}
return tableName + "_" + suffix;
}

private static final int hash(String key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

/**
* 时间段分表
*
* @param tableName
* @param startDate yyyyMMdd
* @param splitDate yyyyMMdd
* @param splitMonth 几个月拆分一张表
* @return
*/

public static String getDateSuffixTableName(String tableName, LocalDate startDate, LocalDate splitDate,
int splitMonth)
{
// 计算月份差
long month = startDate.until(splitDate, ChronoUnit.MONTHS);
long suffix = month / splitMonth;
if (suffix < 10) {
return tableName + "_0" + suffix;
}
return tableName + "_" + suffix;
}

}

总结

通过以上拆分方案,我们成功地解决了拍小租业务快速发展带来的账单表压力问题,并满足了不同产品的查询需求。这些方案不仅提高了系统的性能和稳定性,还为未来的业务发展提供了灵活的扩展空间。

作者简介

  • 督邮 信也科技Tars业务研发专家。

招聘信息

信也科技 · 目录
上一篇分布式文件存储SeaweedFS:数据存储的设计与实现下一篇​KMS - 构建应用的凭证管理防线
继续滑动看下一个
拍码场
向上滑动看下一个