CoreDataにおける大量データ更新の問題点
CoreDataはいわゆるO-Rマッパーフレームワークで、Mac/iOSアプリでは使っている方も多いと思います。
DBなどの永続化ストアをオブジェクトとして扱えるのでとても便利なのですが、高機能である代償としてどうしてもパフォーマンス上不利になってしまう点があります。
その代表例が一度に大量のデータを更新する処理、いわゆる「バッチ更新」です。
O-Rマッパーでデータ更新をする場合、通常はまずいったんメモリ上のオブジェクトして具現化し、オブジェクトのプロパティを変化させて、それをもう一度DBに書き戻すことになります。
通常はこれで問題ないのですが、「10,000件のメールすべてに既読フラグをセットする」というような処理を考えると非常に非効率なことが起こります。
SQLであれば、
update MAIL set READ = 1
とするだけで、MAILテーブルのすべての行のREAD列を1に変更することができます。ところがCoreDataの場合は、以下のようになってしまいます。
- MAILテーブルのすべての行(10,000件)に対応するNSManagedObjectを生成し
- それらのプロパティを変更し、メモリ上に保持
- NSManagedObjectContextの saveが呼ばれたら、変更のあった10,000個のオブジェクトのプロパティを1件ずつDBに適用
非常に効率が悪いですね。
iOS 8以降で使えるCoreDataのバッチ更新機能
iOS 8以降、CoreDataは新たに バッチ更新 の仕組みが用意されました。バッチ更新を使うと NSManagedObjectを経由せずに、直接永続化層(Persistent Store, つまりDBのこと)に変更を適用することができます。
バッチ更新は NSBatchUpdateRequestというクラスを使うのですが、実はこのクラスは公式のClass Referenceが用意されていません。AppleのCoreDataフレームワークのリファレンスを見ても、リンクになっていないのがわかります。
公式の情報としてもっとも有用なのは、2014年のWWDCのセッションビデオの「What's New in CoreData」です。リスニングが苦手な方は、有志がテキスト化しているのでこれをみながらビデオを見ると良いでしょう。
セッションビデオでは、更新対象が数万件ある場合の実演をやっています。普通に更新すると10秒くらいかかっていた処理が、バッチ処理を使うと1秒以下で更新できています。この技術はぜひ使いこなしたいですね。
CoreDataのバッチ更新の使い方
ここからは実際にバッチ更新をする方法を解説します。
すべてのオブジェクトに同じ値をセットする場合
CoreDataにMailエンティティがあるとして、そのすべてのオブジェクトの readプロパティに trueをセットする方法をみてみましょう。
let managedObjectContext = fetchedResultsController.managedObjectContext
let entity = NSEntityDescription.entityForName("Mail", inManagedObjectContext: managedObjectContext)!
let req = NSBatchUpdateRequest(entity: entity)
req.resultType = .UpdatedObjectIDsResultType
req.propertiesToUpdate = ["read":true]
do {
let result = try managedObjectContext.executeRequest(req) as! NSBatchUpdateResult
if let array = result.result as? [NSManagedObjectID] {
array.forEach {
let obj = managedObjectContext.objectWithID($0)
managedObjectContext.refreshObject(obj, mergeChanges: false)
}
}
} catch _ {
print("Error")
}
上記のコードでは以下のことをやっています。
- NSBatchUpdateRequestを作る
- 変更したいプロパティとその値は、propertiesToUpdateにDictionaryとしてセットしておく
- それを ManagedObjectContextの executeRequestメソッドに渡す
基本的にはこれだけなのですが、上記コードにはそれ以外の処理も書かれています。これはなんでしょうか?
バッチ更新の結果をNSManagedObjectに反映させる
バッチ更新はPersistentStoreに直接変更を加えてしまうので、NSManagedObjectはその変更を知らないままです。つまり、DBの既読フラグ(readプロパティ)は trueになったことを知らないため、UIの既読フラグに反映されず、画面上は未読の状態に見えてしまうということです。
そこで、NSManagedObjectContextのrefreshObjectメソッドの出番です。このメソッドにNSManagedObjectを渡すとオブジェクトがフォールティングされるので、その後でプロパティにアクセスされるともう一度再度DBから最新の値を読み直される、というわけです。
refreshObjectは引数にNSManagedObjectが必要です。ということはバッチ更新でどのオブジェクトに変更があったかを知る必要があるということです。NSBatchUpdateRequestの実行結果は、以下の3種類から選べます。
タイプ | 意味 |
---|---|
NSStatusOnlyResultType | (デフォルト)実行成否のステータスのみ |
NSUpdatedObjectIDsResultType | 変更のあったオブジェクトIDのリストを返す |
NSUpdatedObjectsCountResultType | 変更のあった要素の数だけを返す |
デフォルトはNSStatusOnlyResultTypeなので、バッチの成否しかわかりません。
そこで、上記サンプルコードでは、resultTypeプロパティにNSUpdatedObjectIDsResultTypeをセットし、実行結果の詳細として、更新のあったNSManagedObjectのIDが受け取れるようにしています。こうすると executeRequestの結果(NSBatchUpdateResult)の resultプロパティに NSManagedObjectIDの Arrayがセットされて返ってきます。これを使って、refreshObjectを呼び出し、正しい値が読めるようにしているわけです。
ちなみに、バッチ更新の実装を想像すると分かりますが、成否のみがわかる一番上のタイプがもっとも高速です。変更のあったオブジェクトのIDや数を知るためには、バッチ更新をかける前に、対象オブジェクトのIDを取ってきたり、対象の数を数える必要があるので若干オーバーヘッドがあります。
実際にやってみると、うまくいかない事がある
ところが、実際に動かしてみるとNSManagedObjectが古い値を持ち続けてしまい、画面が更新されない、という現象が発生します。(うまく動く場合もあります)
いろいろ調べていくと、以下の事がわかりました。まずは、NSManagedObjectContextの refreshObjectの説明をよく読んでみます。すると、Discussionの冒頭で以下の説明がされています。
If the staleness interval (see stalenessInterval) has not been exceeded, any available cached data is reused instead of executing a new fetch.
もし、staleness interval (stalenessIntervalプロパティの解説を参照)の有効期限が切れていない場合、新規にフェッチを実行するかどうかにかかわらず、キャッシュされたデータは再利用されさます
さらっと書かれていますが、 staleness intervalとはなんでしょうか?これは stalenessIntervalプロパティの説明を見るとわかります。
The maximum length of time that may have elapsed since the store previously fetched data before fulfilling a fault issues a new fetch rather than using the previously-fetched data.
(直訳)フォールティング解消のために以前フェッチされたデータを使わずに新しいフェッチを実行するようになる、前回データをフェッチしてからの経過時間の最大値
(意訳)フォールティングを解消する際、以前フェッチされたデータのキャッシュを使わずに新規に永続化ストアらかフェッチしてくるようになる時間。この時間は以前データをフェッチしてからの経過時間で表されます。
要するに、NSManagedObjectは、faulting状態(プロパティがロードされていない状態)を解消する際に必ずしもPersistentStoreから値を取得するとは限らない、ということです。たとえば staleness interval に 300(秒)が設定されていると、5分間はNSManagedObjectContextがキャッシュしている値を再利用し、DBへのクエリが再発行されないということです。
これは、refreshObjectを明示的に呼び出したした場合でも同じです。staleness intervalが有効な間は、いくらこれを呼び出したとしてもキャッシュされている値があるとそちらが使われてしまうため、PersisntentStoreに反映されたバッチ更新の結果が読み出されません。
staleness intervalはデフォルトでは -1が設定されています。 -1は時間制限なしという意味 で、メモリ上にキャッシュがある限りそれが再利用されてしまいます。
画面が更新されなかったのはこれが理由でした。実際、NSManagedObjectContextを生成する際に
let managedObjectContext = NSManagedObjectContext(...
managedObjectContext.stalenessInterval= 0
というように0を設定するようにしたら問題が起こらなくなりました。
ただし、staleness intervalを0にしてしまうと、今まで以上にDBへのクエリが発行されてしまうことになるので、パフォーマンスへの影響が気になります。
起動時のDBマイグレーション処理などであれば、マイグレーション時に専用のNSManagedObjectContextを生成し、バッチ更新(マイグレーション)が終わった後に、UI用のNSManagedObjectContextを作り直すといったことはできそうです。
ただ、アプリ動作中にバッチ更新をする場合は stalenessIntervalを0にするしかなさそうです。もう少しいい方法がありそうな気もしますが、現時点では見つけられていません。
オブジェクトごとに異なる値を設定する場合
上記例ではすべてのオブジェクトに同じ値(true)を設定していましたが、ブジェクトごとに異なる値を設定することもできます(いろいろな制限はあります)。
プロパティのバッチ更新は、以下のように propertiesToUpdateに key-valueを設定することで実現しています。
req.propertiesToUpdate = ["read":true]
このやり方だと、値は必ず同じになってしまうような気がしますが、実は面白い仕掛けがあります。じつは valueには NSExpressionのインスタンス、つまり 「式」を設定することができる のです。
具体例を見てみましょう。
let exp = NSExpression(format: "property2")
req.propertiesToUpdate = ["property1":exp]
上記コードは「property1に対して property2の値をそのまま設定する」というものです。当然、オブジェクトごと(DBの行ごと)に property2の値は異なりますから、それぞれに異なる値が設定されます。
また、
let expProp2 = NSExpression(format: "property2")
let expMax = NSExpression(forFunction: "max:", arguments:[expProp2])
req.propertiesToUpdate = ["property1":exp]
とすると、property2の最大値が property1にセットされます。
これ以外にもNSExpressionは様々な構文が用意されていて、たとえば、四則演算や Sub Query (副問い合わせ)を設定したりすることもできます。中には、selectorやblockを呼び出したりできるものもあるのですが、 残念ながらすべてがCoreDataで使用できるわけではありません。
バッチ更新のドキュメントが貧弱なので、どれが使えるかは試してみないとわかりません。Fetchの時には使えても、バッチ更新では使えないものもあるようです。
一度時間を作ってちゃんと調べてみたいところです。
雑感
バッチ更新で Core Dataでの大量更新時のパフォーマンスを大きく向上できることがわかりました。しかし、ドキュメントがほとんどなかったり、公式のサンプルコードが無かったりと、なかなか正しい使い方が分かりづらいです。
実アプリに組み込むためにはもう少し研究が必要そうです。
また何か分かり次第本記事もアップデートしていきます。