こんにちは、アプリケーションエンジニアの難波です。

BraveridgeのIoTモニタリングシステムでは、SQL関連の実装にMyBatisを利用しています。
今回はMyBatisのInterceptorを実装する機会があったので、備忘録も兼ねてブログを書きました。

物理削除するか、論理削除するか

DBを操作するアプリケーションを開発するときによく問題となるのは、DBから削除したデータの取り扱いです。

・物理削除 : 実際にデータを削除する
・論理削除 : 削除フラグを立てるだけで、実際にはデータを削除しない

個人的な経験に基づく話になりますが、論理削除は実装したくありません。
論理削除だと削除データを削除しないので、データの肥大化やSQLで常に削除フラグを意識する必要があるためです。

ですが、物理削除だと削除してしまったデータを復活させるには、バックアップからレストアするなど手間がかかります。

どちらも一長一短です。

そこで、削除データを別の場所に保存しておく、という手法を採用します。

TableのTriggerで削除データを残す

SQL Server

SQL Server だと、以下のようにtrashという削除データ保存テーブルを用意してTriggerで実現できます。

trashテーブルのDDL

SQL
コードをコピー

CREATE TABLE trash
(
    trash_id   INT PRIMARY KEY IDENTITY,
    contents   NVARCHAR(max) NOT NULL,
    created_at DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP
)





テーブル table1 からデータを削除したときに実行されるTriggerを定義します。

SQL
コードをコピー

CREATE TRIGGER row_deleted ON table1 FOR DELETE AS
    INSERT INTO trash (contents)
    SELECT (SELECT * FROM deleted AS table1 FOR XML AUTO) FROM deleted
GO



これで、table1からデータを削除すると、trashテーブルにXMLで削除データが保存されます。

ただし、このTriggerには
「table1から大量にデータを削除するときに削除時間が長くなる = 長時間テーブルロックする」
という問題があります。

MySQL

MySQLで同様のTriggerを作成する場合、データを削除するテーブルと同じ項目を持つtrashテーブルを用意する必要があります。
この方法だと、機能追加などでテーブルに項目追加した時に問題が発生することが考えられます。
システム運用を考えるとあまり良い手法ではありません。
 

MyBatis Interceptor

実際のシステム運用では、削除データを復活させる頻度は多くありません。
そのため、削除データをDBのテーブルではなく、ログファイルなどテキストファイルに保存していても、そこまで問題にはなりません。

そこで、MyBatisのInterceptorで、削除したデータをログに出力する機能を実装します。

実装したMyBatisの削除データロガーInterceptorのソースコード (Kotlin)
 

Kotlin
コードをコピー

@Intercepts(
    Signature(type = Executor::class, method = "update", args = [MappedStatement::class, Any::class]),
)
@Component
class SqlDeleteInterceptor : Interceptor {
    override fun intercept(invocation: Invocation): Any {
        return invocation.proceed()
    }
}



InterceptsアノテーションのSignatureに
type = Executor::class, method = "update"
をセットします。

これで、insert, upate, delete を実行する前に独自処理を追加することができます。

Kotlin
コードをコピー

override fun intercept(invocation: Invocation): Any {
    if (invocation.args.count() < 2) {
        return
    }

    val mappedStatement = invocation.args[0] as? MappedStatement ?: return
    if (mappedStatement.sqlCommandType != SqlCommandType.DELETE) {
        return
    } 
    return invocation.proceed()
}


mappedStatement.sqlCommandType をチェックすることで、SQLがdeleteかどうか判別できます。

Kotlin
コードをコピー

val parameter = invocation.args[1]
val boundSql = mappedStatement.getBoundSql(parameter)

val connection = (invocation.target as? Executor)?.transaction?.connection ?: return
val sql = boundSql.sql.replace("DELETE FROM", "SELECT * FROM")
    .let {
        if (it.contains("LIMIT")) it
        else "$it LIMIT 100"
    }
val ps = connection.prepareStatement(sql)
(parameter as? MapperMethod.ParamMap<*>)?.let {
    boundSql.parameterMappings.toList().forEachIndexed { i, parameterMapping ->
        ps.setObject(i + 1, parameter[parameterMapping.property].toString())
    }
} ?: run {
    parameter?.let { ps.setObject(1, it) }
}




boundSql.sql に実行予定のSQL文がセットされています。
select 文に置換して安全のために LIMIT 100 を追加しています。

Kotlin
コードをコピー

ps.executeQuery().use { rs ->
    while (rs.next()) {
        (1..rs.metaData.columnCount)
            .joinToString { rs.getString(it) ?: "" }
            .let { logger.info("Row : $it") }
    }
}



最後にselect文を実行して、取得した内容をログに出力します。

ーーーーーーーーーーーーーーーーー
出力されるログ
Row : value1, value2, value3
ーーーーーーーーーーーーーーーーー
 

テーブルから取得したデータをそのままログファイルに出力すると、個人情報がログに残ってしまう場合があります。
その場合は、テーブル名と項目名を指定してログに書き出す内容から排除するなど、対応する必要があると思います。
 

まとめ

MyBatisのInterceptorで削除データをログ出力する機能を実装しました。
この方法だと、大量データの削除時に、長時間テーブルをロックするなど悪影響がありません。
また、ログ出力する削除データの種類を必要に応じて選択することも可能です。

SNS SHARE