可能出现的问题
- 同步导数据,接口很容易超时。
- 如果把所有数据一次性装载到内存,很容易引起OOM。
- 数据量太大sql语句慢。
- 如果走异步,如何通知用户导出结果
- 如果excel文件太大,目标用户打不开怎么办
解决方案
问题一 异步化 调用接口立即返回任务生产成功
问题二 分批查询 poi 禁止使用XSSFWorkbook 使用SXSSFWorkbook 或 easy Excel
问题三 分页通过滚动翻页查询
流式查询问题:容易长时间占用数据库链接池资源。
游标查询问题:应用指定每次查询获取的条数fetchSize,MySQL服务器每次只查询指定条数的数据,由于MySQL方不知道客户端什么时候将数据消费完,MySQL需要建立一个临时空间来存放每次查询出的数据,大数据量时MySQL服务器、磁盘占用都会飙升。
故使用滚动翻页查询
问题四 通过 页面或者沟通软件通知用户导出成功 ,并将导出结果上传至oss 后续可直接下载 无需重复导出
问题五 导出可用户设置最大条数
数据准备
sql
CREATE TABLE `t_order`
(
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
`creator` VARCHAR(16) NOT NULL DEFAULT 'admin' COMMENT '创建人',
`editor` VARCHAR(16) NOT NULL DEFAULT 'admin' COMMENT '修改人',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`edit_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`version` BIGINT NOT NULL DEFAULT 1 COMMENT '版本号',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标识',
`order_id` VARCHAR(32) NOT NULL COMMENT '订单ID',
`amount` DECIMAL(10, 2) NOT NULL DEFAULT 0 COMMENT '订单金额',
`payment_time` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '支付时间',
`order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态,0:处理中,1:支付成功,2:支付失败',
UNIQUE uniq_order_id (`order_id`),
INDEX idx_payment_time (`payment_time`)
) COMMENT '订单表';
java
public class OrderServiceTest {
private static final Random OR = new Random();
private static final Random AR = new Random();
private static final Random DR = new Random();
@Test
public void testGenerateTestOrderSql() throws Exception {
HikariConfig config = new HikariConfig();
config.setUsername("root");
config.setPassword("root");
config.setJdbcUrl("jdbc:mysql://localhost:3306/local?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&useSSL=false");
config.setDriverClassName("com.mysql.jdbc.Driver");
HikariDataSource hikariDataSource = new HikariDataSource(config);
JdbcTemplate jdbcTemplate = new JdbcTemplate(hikariDataSource);
for (int d = 0; d < 100; d++) {
String item = "('%s','%d','2020-07-%d 00:00:00','%d')";
StringBuilder sql = new StringBuilder("INSERT INTO t_order(order_id,amount,payment_time,order_status) VALUES ");
for (int i = 0; i < 20_000; i++) {
sql.append(String.format(item, UUID.randomUUID().toString().replace("-", ""),
AR.nextInt(100000) + 1, DR.nextInt(31) + 1, OR.nextInt(3))).append(",");
}
jdbcTemplate.update(sql.substring(0, sql.lastIndexOf(",")));
}
hikariDataSource.close();
}
}
具体实现
easy Excel通过滚动翻页
Controller
java
@GetMapping(path = "/export")
public void export(@RequestParam(name = "paymentDateStart") String paymentDateStart,
@RequestParam(name = "paymentDateEnd") String paymentDateEnd,
) throws Exception {
orderService.export(paymentDateStart, paymentDateEnd);
}
Service
java
@Async
public void export(String paymentDateStart, String paymentDateEnd) throws IOException {
Date dateBefore = new Date();
String fileName = URLEncoder.encode(String.format("%s-(%s).xlsx", "订单数据", UUID.randomUUID()),
StandardCharsets.UTF_8.toString());
BufferedOutputStream outputStream = FileUtil.getOutputStream(FileUtil.file("/Users/Documents/github/"+fileName));
ExcelWriter writer = new ExcelWriterBuilder()
.autoCloseStream(true)
.excelType(ExcelTypeEnum.XLSX)
.file(outputStream)
.head(OrderDTO.class)
.build();
WriteSheet writeSheet = new WriteSheet();
writeSheet.setSheetName("target");
long lastBatchMaxId = 0L;
int limit = 3000;
for (; ; ) {
List<OrderDTO> list = queryByScrollingPagination(paymentDateTimeStart, paymentDateTimeEnd, lastBatchMaxId, limit);
//可以添加导出条数限制
if (list.isEmpty()) {
writer.finish();
Date dateAfter = new Date();
System.out.println("导出列表共执行" + (dateAfter.getTime() - dateBefore.getTime()) + "ms");
//todo 上传oss 发通知
break;
} else {
lastBatchMaxId = list.stream().map(OrderDTO::getId).max(Long::compareTo).orElse(Long.MAX_VALUE);
writer.write(list, writeSheet);
}
}
}
java
public List<OrderDTO> queryByScrollingPagination(String paymentDateTimeStart,
String paymentDateTimeEnd,
long lastBatchMaxId,
int limit) {
LocalDateTime start = LocalDateTime.parse(paymentDateTimeStart, formatter);
LocalDateTime end = LocalDateTime.parse(paymentDateTimeEnd, formatter);
return orderDao.queryByScrollingPagination(lastBatchMaxId, limit, start, end).stream().map(order -> {
OrderDTO dto = new OrderDTO();
dto.setId(order.getId());
dto.setAmount(order.getAmount());
dto.setOrderId(order.getOrderId());
dto.setCreator(order.getCreator());
return dto;
}).collect(Collectors.toList());
}
Repository
java
@Repository
public class OrderDao {
@Resource
private JdbcTemplate jdbcTemplate;
public List<Order> queryByScrollingPagination(long lastBatchMaxId,
int limit,
LocalDateTime paymentDateTimeStart,
LocalDateTime paymentDateTimeEnd) {
return jdbcTemplate.query("SELECT id,creator,editor ,version,deleted,order_id,amount,order_status FROM t_order WHERE id > ? AND payment_time >= ? AND payment_time <= ? " +
"ORDER BY id ASC LIMIT ?",
p -> {
p.setLong(1, lastBatchMaxId);
p.setTimestamp(2, Timestamp.valueOf(paymentDateTimeStart));
p.setTimestamp(3, Timestamp.valueOf(paymentDateTimeEnd));
p.setInt(4, limit);
},
rs -> {
List<Order> orders = new ArrayList<>();
while (rs.next()) {
Order order = new Order();
order.setId(rs.getLong("id"));
order.setCreator(rs.getString("creator"));
order.setEditor(rs.getString("editor"));
order.setVersion(rs.getLong("version"));
order.setDeleted(rs.getInt("deleted"));
order.setOrderId(rs.getString("order_id"));
order.setAmount(rs.getBigDecimal("amount"));
order.setOrderStatus(rs.getInt("order_status"));
orders.add(order);
}
return orders;
});
}
}
总结
业务方面
做需求时刻先考虑是不是必须要做 、如果必须要做的情况需要考虑用户的体验和使用感受
技术方面
1 不需要立马返回结果的接口可以采用异步的方式让接口立刻返回结果,可以防止接口耗时过长导致tomcat线程池打满。
2 MySQL批量查询、数据同步、数据导出可以使用类似于分页查询的思路,但是鉴于LIMIT offset,size的效率太低,可以采用"滚动翻页"的实现方式 注意要用自增趋势的主键
3 数据导出需要注意由于大对象频繁创建导致的 full gc 和oom 如果导出较频繁可以考虑拆分单独服务专门做导出