saveAll()がデータを更新するのではなく常に挿入するのはなぜですか?

Dec 06 2020

Spring Boot 2.4.0、DBはMySql8です。

データはRESTを使用してリモートから15秒ごとにフェッチされ、を使用してMySqlDBに保存されsaveAll()ます。

指定されたすべてのエンティティに対してsave()メソッドを呼び出します。

すべてのデータにIDが設定されています。
そして、DBにそのようなIDがない場合は、挿入されることを期待しています。
そのようなIDがすでにDBに提示されている場合は、更新されます。

これはコンソールから抜粋したものです:

Hibernate: 
    insert 
    into
        iot_entity
        (controller_ref, description, device_id, device_ref, entity_type_ref, hw_address, hw_serial, image_ref, inventory_nr, ip6address1, ip6address2, ip_address1, ip_address2, latlng, location, mac_address, name, params, status, tenant, type, id) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
...
2020-12-05 23:18:28.269 ERROR 15752 --- [  restartedMain] o.h.e.jdbc.batch.internal.BatchingBatch  : HHH000315: Exception executing batch [java.sql.BatchUpdateException: Duplicate entry '1' for key 'iot_entity.PRIMARY'], SQL: insert into iot_entity (controller_ref, description, device_id, device_ref, entity_type_ref, hw_address, hw_serial, image_ref, inventory_nr, ip6address1, ip6address2, ip_address1, ip_address2, latlng, location, mac_address, name, params, status, tenant, type, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2020-12-05 23:18:28.269  WARN 15752 --- [  restartedMain] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
2020-12-05 23:18:28.269 ERROR 15752 --- [  restartedMain] o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry '1' for key 'iot_entity.PRIMARY'
2020-12-05 23:18:28.269 DEBUG 15752 --- [  restartedMain] o.s.orm.jpa.JpaTransactionManager        : Initiating transaction rollback after commit exception

org.springframework.dao.DataIntegrityViolationException: could not execute batch; SQL [insert into iot_entity (controller_ref, description, device_id, device_ref, entity_type_ref, hw_address, hw_serial, image_ref, inventory_nr, ip6address1, ip6address2, ip_address1, ip_address2, latlng, location, mac_address, name, params, status, tenant, type, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)]; constraint [iot_entity.PRIMARY]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute batch

フェッチして保存する方法は次のようになります。

@Override
@SneakyThrows
@Scheduled(fixedDelay = 15_000)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void fetchAndStoreData() {
    IotEntity[] entities = restTemplate.getForObject(properties.getIotEntitiesUrl(), IotEntity[].class);

    log.debug("ENTITIES:\n{}", mapper.writerWithDefaultPrettyPrinter().writeValueAsString(entities));

    if (entities != null && entities.length > 0) {
        entityRepository.saveAll(List.of(entities));
    } else {
        log.warn("NO entities data FETCHED !!!");
    }
}

このメソッドは15秒ごとに実行さます。

エンティティ:

@Data
@Entity
@NoArgsConstructor
@EqualsAndHashCode(of = {"id"})
@ToString(of = {"id", "deviceId", "entityTypeRef", "ipAddress1"})
public class IotEntity implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    private Integer id;
    // other fields

およびリポジトリ:

public interface EntityRepository extends JpaRepository<IotEntity, Integer> {
}

これは、JSON形式のiotエンティティの一部です。

2020-12-05 23:18:44.261 DEBUG 15752 --- [pool-3-thread-1] EntityService : ENTITIES:
[ {
  "id" : 1,
  "controllerRef" : null,
  "name" : "Local Controller Unterföhring",
  "description" : "",
  "deviceId" : "",
  ...

だからIDは間違いなく設定されています。

また、プロジェクトのバッチ処理が有効になっています。保存に影響はありません。

既存のエンティティを更新する代わりに新しいエンティティを挿入しようとする理由がわかりませんでしたか?
古いエンティティと新しいエンティティの違いを区別できなかったのはなぜですか?


更新:

エンティティに永続的に実装:

@Data
@Entity
@NoArgsConstructor
@EqualsAndHashCode(of = {"id"})
@ToString(of = {"id", "deviceId", "entityTypeRef", "ipAddress1"})
public class IotEntity implements Serializable, Persistable<Integer> {
    private static final long serialVersionUID = 1L;

    @Id
    private Integer id;

    @Override
    public boolean isNew() {
        return false;
    }

    @Override
    public Integer getId() {
        return this.id;
    }

ただし、同じ例外で失敗します- Duplicate entry '1' for key 'iot_entity.PRIMARY'

@GeneratedValue次のように追加する場合:

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;

それは失敗しないでしょう。ただし、ID値は自動的に更新されます。

たとえば、次のようにフェッチしますid = 15

[ {
  "id" : 15,
  "carParkRef" : 15,
  "name" : "UF Haus 1/2",

そして、次のように保存する必要があります。

実際には、id = 2代わりに次のようになっています。

そしてそれは正しくありません。


保存サービスに追加しようとしました:

private final EntityManager entityManager;
...
List.of(carParks).forEach(entityManager::merge);

同じ例外で失敗します(Persistableの実装の有無にかかわらず)。値を挿入しようとします-insert into ... Duplicate entry '15' for key '... .PRIMARY'

スニペットからapplication.yml

spring:
  # ===============================
  # = DATA SOURCE
  # ===============================
  datasource:
    url: jdbc:mysql://localhost:3306/demo_db
    username: root
    password: root
    initialization-mode: always

  # ===============================
  # = JPA / HIBERNATE
  # ===============================
  jpa:
    show-sql: true
    generate-ddl: true
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true
        generate_statistics: true

ここでは、pomファイルの内容を確認できます。

この問題を修正するにはどうすればよいですか?

回答

2 crizzis Dec 07 2020 at 00:44

問題は、@Idがマークされていないため@GeneratedValue、Spring Dataは、渡されたすべての切り離された(一時的な)エンティティがそれらsave()/saveAll()に対してEntityManager.persist()呼び出されるべきであると想定している可能性があります。

作ってみましょうIotEntity実装Persistableして戻ってfalseからisNew()。これにより、Spring Dataは常にEntityManager.merge()代わりに使用するように指示されます。これにより、目的の効果が得られます(つまり、存在しないエンティティを挿入し、既存のエンティティを更新します)。

catch23 Dec 14 2020 at 19:10

私はこの行動の根源を見つけたようです。

メインアプリランチャーは次のようになります。

@AllArgsConstructor
@SpringBootApplication
public class Application implements CommandLineRunner {

    private final DataService dataService;
    private final QrReaderServer qrReaderServer;
    private final MonitoringService monitoringService;

    @Override
    public void run(String... args) {
        dataService.fetchAndStoreData();
        monitoringService.launchMonitoring();
        qrReaderServer.launchServer();
    }

3つのステップすべてに厳密な実行シーケンスがあります。また、必要に応じて、最初のデータをローカルで更新するために繰り返す必要があります。保存されたデータのみを処理する他の2つのサーバー。

最初の方法は次のようになります。

@Scheduled(fixedDelay = 15_000)
public void fetchAndStoreData() {
    log.debug("START_DATA_FETCH");

    carParkService.fetchAndStoreData();
    entityService.fetchAndStoreData();
    assignmentService.fetchAndStoreData();
    permissionService.fetchAndStoreData();
    capacityService.fetchAndStoreData();

    log.debug("END_DATA_FETCH");
}

また、この実行もスケジュールされています。

アプリが起動すると、このフェッチを2回実行しようとしました。

2020-12-14 14:00:46.208 DEBUG 16656 --- [pool-3-thread-1] c.s.s.s.data.impl.DataServiceImpl        : START_DATA_FETCH
2020-12-14 14:00:46.208 DEBUG 16656 --- [  restartedMain] c.s.s.s.data.impl.DataServiceImpl        : START_DATA_FETCH

2つのスレッドが同じキャッチで実行され、並行して格納されます-insertデータを取得しようとします。(テーブルは開始するたびに再作成されます)。

それ以降のフェッチはすべて問題なく、@Sceduledスレッドによってのみ実行されます。

コメントの場合@Sceduled-例外なく正常に動作します。


解決:

サービスクラスに追加のブールプロパティを追加しました。

@Getter
private static final AtomicBoolean ifDataNotFetched = new AtomicBoolean(true);

@Override
@Scheduled(fixedDelay = 15_000)
@Order(value = Ordered.HIGHEST_PRECEDENCE)
public void fetchAndStoreData() {
    ifDataNotFetched.set(true);
    log.debug("START_DATA_FETCH");

    // fetch and store data with `saveAll()`

    log.debug("END_DATA_FETCH");
    ifDataNotFetched.set(false);
}

そして、アプリケーションの開始後に値を制御します。

@Value("${sharepark.remote-data-fetch-timeout}")
private int dataFetchTimeout;
private static int fetchCounter;

@Override
public void run(String... args) {
    waitRemoteDataStoring();
    monitoringService.launchMonitoring();
    qrReaderServer.launchServer();
}

private void waitRemoteDataStoring() {
    do {
        try {
            if (fetchCounter == dataFetchTimeout) {
                log.warn("Data fetch timeout reached: {}", dataFetchTimeout);
            }

            Thread.sleep(1_000);

            ++fetchCounter;
            log.debug("{} Wait for data fetch one more second...", fetchCounter);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    } while (DataServiceImpl.getIfDataNotFetched().get() && fetchCounter <= dataFetchTimeout);
}
ZafarAli Dec 19 2020 at 00:36

Spring Data JPAは、@ version @Idフィールドの組み合わせを使用して、マージするか挿入するかを決定します。

  • null @ idおよびnull @ versionは新しいレコードを意味するため、挿入します
  • @idが存在する場合、@ versionフィールドは、マージするか挿入するかを決定するために使用されます。
  • 更新は、(update .... where id = xxx and version = 0)の場合にのみ呼び出されます。

@idと@versionが欠落しているため、挿入しようとしています。これは、基になるシステムがこれが新しいレコードであると判断し、sqluを実行するとエラーが発生するためです。