使用.transactionally时Scala Slick主键被跳过的问题
事务失败后主键自增跳号的解决方案
问题场景
我有两个POST路由:
path("employee"):用于单条员工数据插入path("transaction"):通过事务批量插入两条员工数据
故意传入长度超过255字符的lastName字段(数据库该列最大长度限制为255),触发预期错误:
ERROR: value too long for type character varying(255)
但事务请求失败后,再调用employee接口插入数据时,数据能成功存入,但主键ID出现跳号(例如之前主键到5,失败后插入的新数据ID直接变为7,跳过了6)。
相关代码
路由代码
path("transaction") { post { entity(as[String]) { data => complete { controller.insertEmployeeTwice(data).map { res => HttpResponse(status = StatusCodes.OK, entity = HttpEntity(MediaTypes.`application/json`, compact(Extraction.decompose(res)))) } } } } }
控制器代码
def insertEmployeeTwice(data: String): Future[EmployeeResult] = { val employee = data.parseJson.convertTo[Employee] ImplEmployeeRepository.insertTwice(employee) }
仓库实现代码
def insertTwice(row: Employee): Future[DbEmployee] = { val userId = 10 val uuid = UUID.randomUUID().toString val timeStamp = Timestamp.valueOf(new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(new java.util.Date())) val saveData1 = DbEmployee(uuid, row.firstName, row.lastName, row.address, row.phoneNumber, row.age, timeStamp, userId) val lengthLastName = "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec" val saveData2 = DbEmployee(uuid, row.firstName, lengthLastName, row.address, row.phoneNumber, row.age, timeStamp, userId) insertTwoRows(saveData1, saveData2) }
基础仓库代码
def insertTwoRows(r1: E, r2: E): Future[E] = { db.run(insertTwoRowsQuery(r1, r2)) } def insertTwoRowsQuery(row1: E, row2:E): DBIOAction[E, NoStream, Effect.Write with Effect.Transactional] = { (for { _ <- query returning query += row1 r <- query returning query += row2 } yield r).transactionally }
问题原因与解决方案
原因
从错误信息判断使用的是PostgreSQL,其自增主键依赖序列实现:当事务执行插入操作时,序列会先分配ID,即使后续事务回滚,已分配的序列值不会被回退,导致后续插入时跳过这些已分配的ID。
解决办法
1. 提前校验数据长度(推荐)
在进入数据库操作前,先对字段长度做校验,避免触发数据库级错误,从根源减少事务回滚:
// 新增字段校验方法 def validateEmployee(emp: Employee): Either[String, Employee] = { if (emp.lastName.length > 255) Left("lastName长度不能超过255字符") else Right(emp) } // 在insertTwice中先执行校验 def insertTwice(row: Employee): Future[DbEmployee] = { validateEmployee(row) match { case Left(err) => Future.failed(new IllegalArgumentException(err)) case Right(_) => // 原有插入逻辑 val userId = 10 val uuid = UUID.randomUUID().toString val timeStamp = Timestamp.valueOf(new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(new java.util.Date())) val saveData1 = DbEmployee(uuid, row.firstName, row.lastName, row.address, row.phoneNumber, row.age, timeStamp, userId) val lengthLastName = "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec" val saveData2 = DbEmployee(uuid, row.firstName, lengthLastName, row.address, row.phoneNumber, row.age, timeStamp, userId) insertTwoRows(saveData1, saveData2) } }
2. 手动重置序列(仅应急使用)
如果业务强制要求ID连续,可以在事务失败后手动重置序列到当前最大主键值,但此方法存在并发风险,仅适合低并发场景:
// 定义重置序列的SQL执行方法 def resetEmployeeIdSequence(): Future[Unit] = { val resetSql = SQL("SELECT setval('employee_id_seq', (SELECT MAX(id) FROM employee));") db.run(resetSql.asUpdate).map(_ => ()) } // 在事务失败时调用重置逻辑 controller.insertEmployeeTwice(data).recover { case e: Exception => resetEmployeeIdSequence().flatMap(_ => Future.failed(e)) }
3. 接受序列跳号(业务允许时)
序列跳号不影响数据唯一性和业务逻辑,只是ID不连续。如果业务对ID连续性没有强制要求,这是最省心的方案——自增序列的设计初衷是保证唯一性,而非连续性。
内容的提问来源于stack exchange,提问作者Always_a_learner
相关产品推荐
相关产品推荐

