В этой статье пойдет речь о том как ускорить запись большого объема информации в реляционную базу данных для приложений, написанных с использованием Spring Boot. При записи большого количества строк за раз Hibernate вставляет их по одному, что приводит к существенному ожиданию, если строк много. Рассмотрим кейс как это обойти.
Используем Spring Boot приложение. В качестве СУБД -> MS SQL Server, в качестве языка программирования- Kotlin. Разумеется для Java разницы не будет.
Entity для данных, которые нам необходимо записывать:
@Entity
@Table(schema = BaseEntity.schemaName, name = GoodsPrice.tableName)
data class GoodsPrice(
@Id
@Column(name = "GoodsPriceId")
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long,
@Column(name = "GoodsId")
val goodsId: Long,
@Column(name = "Price")
val price: BigDecimal,
@Column(name = "PriceDate")
val priceDate: LocalDate
): BaseEntity(id) {
companion object {
const val tableName: String = "GoodsPrice"
}
}
SQL:
CREATE TABLE [dbo].[GoodsPrice](
[GoodsPriceId] [int] IDENTITY(1,1) NOT NULL,
[GoodsId] [int] NOT NULL,
[Price] [numeric](18, 2) NOT NULL,
[PriceDate] nvarchar(10) NOT NULL,
CONSTRAINT [PK_GoodsPrice] PRIMARY KEY(GoodsPriceId))
В качестве демонстрационного примера будем предполагать, что нам необходимо записывать по 20 000 и по 50 000 записей.
Создадим контроллер, который будет генерировать данные и передавать их на запись и логгировать время:
@RestController
@RequestMapping("/api")
class SaveDataController(private val goodsPriceService: GoodsPriceService) {
@PostMapping("/saveViaJPA")
fun saveDataViaJPA(@RequestParam count: Int) {
val timeStart = System.currentTimeMillis()
goodsPriceService.saveAll(prepareData(count))
val secSpent = (System.currentTimeMillis() - timeStart) / 60
logger.info("Seconds spent : $secSpent")
}
private fun prepareData(count: Int) : List<GoodsPrice> {
val prices = mutableListOf<GoodsPrice>()
for (i in 1..count) {
prices.add(GoodsPrice(
id = 0L,
priceDate = LocalDate.now().minusDays(i.toLong()),
goodsId = 1L,
price = BigDecimal.TEN
))
}
return prices
}
companion object {
private val logger = LoggerFactory.getLogger(SaveDataController::class.java)
}
}
Так же создадим сервис для записи данных и репозиторий GoodsPriceRepository
@Service
class GoodsPriceService(
private val goodsPriceRepository: GoodsPriceRepository
) {
private val xmlMapper: XmlMapper = XmlMapper()
fun saveAll(prices: List<GoodsPrice>) {
goodsPriceRepository.saveAll(prices)
}
}
После этого последовательно вызовем наш метод saveDataViaJPA для 20 000 записей и 50 000 записей.
Консоль:
Hibernate: insert into dbo.GoodsPrice (GoodsId, Price, PriceDate) values (?, ?, ?)
Hibernate: insert into dbo.GoodsPrice (GoodsId, Price, PriceDate) values (?, ?, ?)
Hibernate: insert into dbo.GoodsPrice (GoodsId, Price, PriceDate) values (?, ?, ?)
Hibernate: insert into dbo.GoodsPrice (GoodsId, Price, PriceDate) values (?, ?, ?)
Hibernate: insert into dbo.GoodsPrice (GoodsId, Price, PriceDate) values (?, ?, ?)
Hibernate: insert into dbo.GoodsPrice (GoodsId, Price, PriceDate) values (?, ?, ?)
Hibernate: insert into dbo.GoodsPrice (GoodsId, Price, PriceDate) values (?, ?, ?)
Hibernate: insert into dbo.GoodsPrice (GoodsId, Price, PriceDate) values (?, ?, ?)
Hibernate: insert into dbo.GoodsPrice (GoodsId, Price, PriceDate) values (?, ?, ?)
Hibernate: insert into dbo.GoodsPrice (GoodsId, Price, PriceDate) values (?, ?, ?)
Hibernate: insert into dbo.GoodsPrice (GoodsId, Price, PriceDate) values (?, ?, ?)
Hibernate: insert into dbo.GoodsPrice (GoodsId, Price, PriceDate) values (?, ?, ?)
Hibernate: insert into dbo.GoodsPrice (GoodsId, Price, PriceDate) values (?, ?, ?)
Hibernate: insert into dbo.GoodsPrice (GoodsId, Price, PriceDate) values (?, ?, ?)
2020-11-10 19:11:58.886 INFO 10364 --- [ restartedMain] xmlsave.controller.SaveDataController : Seconds spent : 63
Проблема заключается в том что Hibernate пытался встававить каждую строку отдельным запросом, то есть 20 000 раз. И на моей машине это заняло 63 сек.
Для 50 000 записей 166 сек.
Решение
Что можно сделать? Главная идея заключается в том, что будем записывать через буфферную таблицу:
@Entity
@Table(schema = BaseEntity.schemaName, name = SaveBuffer.tableName)
data class SaveBuffer(
@Id
@Column(name = "BufferId")
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long,
@Column(name = "UUID")
val uuid: String,
@Column(name = "xmlData")
val xmlData: String
): BaseEntity(id) {
companion object {
const val tableName: String = "SaveBuffer"
}
}
SQL script для таблицы в базе данных
CREATE TABLE [dbo].[SaveBuffer](
[BufferId] [int] IDENTITY NOT NULL,
[UUID] [varchar](64) NOT NULL,
[xmlData] [xml] NULL,
CONSTRAINT [PK_SaveBuffer] PRIMARY KEY (BufferId))
В SaveDataController добавим метод:
@PostMapping("/saveViaBuffer")
fun saveViaBuffer(@RequestParam count: Int) {
val timeStart = System.currentTimeMillis()
goodsPriceService.saveViaBuffer(prepareData(count))
val secSpent = (System.currentTimeMillis() - timeStart) / 60
logger.info("Seconds spent : $secSpent")
}
Так же добавим в GoodsPriceService метод:
@Transactional
fun saveViaBuffer(prices: List<GoodsPrice>) {
val uuid = UUID.randomUUID().toString()
val values = prices.map {
BufferDTO(
goodsId = it.goodsId,
priceDate = it.priceDate.format(DateTimeFormatter.ISO_DATE),
price = it.price.stripTrailingZeros().toPlainString()
)
}
bufferRepository.save(
SaveBuffer(
id = 0L,
uuid = uuid,
xmlData = xmlMapper.writeValueAsString(values)
)
)
goodsPriceRepository.saveViaBuffer(uuid)
bufferRepository.deleteAllByUuid(uuid)
}
Для записи для начала генерим уникальный uuid, чтобы отличить текущие данные, которые записываем. Далее записываем наши данные в созданный буффер текстом в виде xml. То есть будет не 20 000 инсертов, а всего 1.
И после этого перебрасываем одним запросом типа Insert into… select данные из буффера в таблицу GoodsPrice.
GoodsPriceRepository с методом saveViaBuffer:
@Repository
interface GoodsPriceRepository: JpaRepository<GoodsPrice, Long> {
@Modifying
@Query("""
insert into dbo.GoodsPrice(
GoodsId,
Price,
PriceDate
)
select res.*
from dbo.SaveBuffer buffer
cross apply(select temp.n.value('goodsId[1]', 'int') as GoodsId
, temp.n.value('price[1]', 'numeric(18, 2)') as Price
, temp.n.value('priceDate[1]', 'nvarchar(10)') as PriceDate
from buffer.xmlData.nodes('/ArrayList/item') temp(n)) res
where buffer.UUID = :uuid
""", nativeQuery = true)
fun saveViaBuffer(uuid: String)
}
И в конце для того, чтобы не хранить в базе дублированную информацию удаляем по uuid данные из буффера.
Вызовем наш метод saveViaBuffer для 20 000 строк и 50 000 строк:
Hibernate: insert into dbo.SaveBuffer (UUID, xmlData) values (?, ?)
Hibernate: insert into dbo.SaveBuffer (UUID, xmlData) values (?, ?)
Hibernate: insert into dbo.SaveBuffer (UUID, xmlData) values (?, ?)
Hibernate: insert into dbo.SaveBuffer (UUID, xmlData) values (?, ?)
Hibernate:
insert into dbo.GoodsPrice(
GoodsId,
Price,
PriceDate
)
select res.*
from dbo.SaveBuffer buffer
cross apply(select temp.n.value('goodsId[1]', 'int') as GoodsId
, temp.n.value('price[1]', 'numeric(18, 2)') as Price
, temp.n.value('priceDate[1]', 'nvarchar(10)') as PriceDate
from buffer.xmlData.nodes('/ArrayList/item') temp(n)) res
where buffer.UUID = ?
Hibernate: select savebuffer0_.BufferId as bufferid1_1_, savebuffer0_.UUID as uuid2_1_, savebuffer0_.xmlData as xmldata3_1_ from dbo.SaveBuffer savebuffer0_ where savebuffer0_.UUID=?
Hibernate: delete from dbo.SaveBuffer where BufferId=?
Hibernate: delete from dbo.SaveBuffer where BufferId=?
Hibernate: delete from dbo.SaveBuffer where BufferId=?
Hibernate: delete from dbo.SaveBuffer where BufferId=?
2020-11-10 20:01:58.788 INFO 7224 --- [ restartedMain] xmlsave.controller.SaveDataController : Seconds spent : 13
Как видим по результатам получили существенное ускорение записи данных.
Для 20 000 записей 13 секунд было 63.
Для 50 000 записей 27 секунд было 166.
Ссылка на тестовый проект
Peter1010
Что-то странная статья. На сколько я помню в Hibernate есть Batch Insert. Судя по статье это именно то что вам нужно.
ArsenAbakarov
Пишу код на python, и прочитав статью подумал — неужели там нет bulk insertа какого-нибудь, видимо все же есть
Graf54r
Абсолютно верно! Есть batch, его размер задается через проперти. У спринга чуть по другому называется.
Есть проблема над решением которой дествительно можно было бы написать статью:
При вставке новых больших данных с id!=null, хибер сначала лезет в БД и узнает есть ли уже такая запись, если нет, делает insert иначе update. Получается на 20К вставок идет 20К select.
Тут варианты:
— либо писать ручками — и довольно много ручного труда
— либо сущность наследовать от Interface Persistable переоперделять метод isNew [не самый безопасный вариант, ломается логика save, delete и возможно еще каких-либо]
Я предпочел ручной вариант хоть и с postgreSql при этом приходится повозится, т.к. если нужно чтобы запись при вставке уже существующего id обновлялась, приходится все колонки дублировать.
sshikov
>Тут варианты:
>— либо писать ручками — и довольно много ручного труда
Ну вообще у MS SQL есть merge, и труда там не так уж и много. Нестандартно, необычно — может быть, но не более.
Graf54r
все равно запрос писать вручную. И про MS SQL речи не было.
sshikov
Ну да, вручную. Я про это и говорю — что этого ручного труда не так и много, как может показаться.
>И про MS SQL речи не было.
У автора как раз все про него. У других есть аналоги, merge не самый удобный.
mamento
Можете попробовать, если у вас uuid. Ну или с другими генераторами поэкспериментировать.