Salesforceのちょっと辛い話
Salesforceのサーバ上でカスタムのロジックを動かしたいときには、必ずApexというSalesforceのプラットフォームのみで利用可能な独自言語を使うのですが、このプラットフォームにはガバナ制約(Governor Limit)という非常に残念な制限がありまして、ヒープメモリを大きく消費したりDBへの過度なアクセスをするコードをシャットダウンするようになっています。Salesforceではマルチテナント環境でコードが実行されるため、他のテナントに影響を与えないようにこういった制限を導入しているのですが、Apexが初めて登場した2005年ならまだしも、昨今ではこんなのコンテナレベルでやってくれよ、という感じではあります。
まあそれはさておき、Salesforceを使う以上、そういった制約があるのは仕方ないので、しぶしぶ従うわけです。しかしながら、メモリやCPU時間の消費、DBクエリの回数で制限されるのはまだしも、中には冗談としか思えないような制約があったりします。その代表とも言えるのが、
『オブジェクト(RDBで言うテーブルに相当する)中のレコードを検索するとき、50,000件以上ヒットする場合はレコードの件数が取得できない』
これは、Apexコードでは1トランザクション中のレコード取得数の制限が50,000までに設定されているためです。
でもおかしいですよね。たとえば SELECT count() FROM XXXX
みたいなクエリで取得しているのは件数だけなので、50,000件取得していると言われても困ります。
DBに負荷を与えるようなクエリがダメというなら、100万件をインデックスを使わずテーブルフルスキャンで検索して1件のみしかヒットしないようなクエリでも相応の負荷がかかりそうなものです。でもこれは最終的に取得したレコードは1件なので、制限を1しか消費しないんですよね。
もしフェッチの際の転送量が問題なのだとして、件数を取るのにまさかデータベースから全レコード取ってこないです?よね?
何か制限の指標が間違っているとしか思えない。
回避方法あれこれ
これみんなずっと悩んでいるらしく、なんとかして解決する方法がいろいろ考え出されています、
Apexコードから SOAP/REST WebサービスをHTTPコールアウトする
Salesforceのデータベースへのクエリは、SOAPおよびREST形式のWebサービスAPIからも実行できるのですが、実はAPIコールのほうが制約が緩かったりします。APIコールでは数十万件のcountもAggregation(GROUP BY)も件数で制限されることはありません(別途タイムアウトに引っかかることはあります)。
なので、Apexから自分自身(=Salesforce)にループバックしてWebサービスAPIを起動するという方法が一つあります。
String soql = 'SELECT count() FROM BigTable__c';
String u = URL.getSalesforceBaseUrl().toExternalForm();
u += '/services/data/v32.0/query?q=' + EncodingUtil.urlEncode(soql, 'utf8'); // REST Webサービス
Http h = new Http();
HttpRequest req = new HttpRequest();
req.setMethod('GET');
req.setEndpoint(u);
req.setHeader('Authorization', 'Bearer '+UserInfo.getSessionId());
HttpResponse res = h.send(req);
Map<String, Object> body =
(Map<String, Object>)JSON.deserializeUntyped(res.getBody());
Integer count = (Integer) body.get('totalSize');
System.debug(count);
この方法の問題点は、ループバックとはいえ外部サイトの呼び出し扱いになるので、リモートサイト設定にURLを登録しなければならないという点です。SalesforceのURLは開発組織やリージョンごと、はたまたマイドメイン設定を行っているかどうかでも変わってきますので、コードの配布の際に常にリモートサイトの設定を気遣うことになり、結構煩雑です。パッケージでの配布はちょっと厳しいかもしれません。あともちろんAPI実行に制限がある組織だとダメですね。
Visualforceから SOAP/REST WebサービスAPIを実行する
UIをVisualforceで作っている場合限定です。HTMLからJavaScriptを介してAPIコールを行って件数を取得します。サーバロジック単体の場合は使えませんので、前述のHTTPコールアウトを行うことになるでしょう。HTTPコールアウトと異なり、リモートサイトの設定は必要ありませんが、API実行はやはり有効になっている必要があります。
SOAP APIの場合は Ajax Toolkit (connection.js)、REST APIの場合はjsforceなどが良さげです。
事前にサマリを実行しておく
Salesforceには分析スナップショットという機能があります。事前にレポートなどで設定した検索条件で集計値を取ったものを、別のオブジェクトに保管する方法です。
事前に検索条件が決まっているならまだしも、動的に検索条件を構築してクエリを行いたいときにはむずかしいでしょう。また、ある時点でのスナップショットになるので、リアルタイムの件数ではありません。
積み上げ集計を使う
主従関係を持つ子供のオブジェクト限定で使えるものです。汎用性に欠けるやり方です。
Apex Batchを使う
バッチでの実行であれば、ガバナの制限は緩めになります。
でも強制的に非同期になるので、同期的に結果を取りたい場合はこれも使えません。
そもそもオンライン処理で使うようなことが想定されたものではないのでしょう。
もう一つの方法
とりあえず、与えられたガバナ制限をがんがんに使えば、同期でも約250,000件までは件数を取得できますよ、というPoCです。
5倍になって何か意味があるのかはわかりませんが。
Integer totalCnt = 0;
Boolean done = false;
BigTable__c[] recs = null;
Id lastId = null;
for (Integer i=0; i<99; i++) {
if (lastId == null) {
recs = [
SELECT Id FROM BigTable__c
ORDER BY Id ASC
LIMIT 1
OFFSET 2000
];
} else {
recs = [
SELECT Id FROM BigTable__c
WHERE Id > :lastId
ORDER BY Id ASC
LIMIT 1
OFFSET 2000
];
}
if (recs.size() == 0) {
break;
} else {
totalCnt += 2001;
lastId = recs[0].Id;
}
}
if (lastId == null) {
recs = [
SELECT Id FROM BigTable__c
ORDER BY Id ASC
LIMIT 49900
];
} else {
recs = [
SELECT Id FROM BigTable__c
WHERE Id > :lastId
ORDER BY Id ASC
LIMIT 49900
];
}
if (recs.size() < 49900) {
done = true;
}
totalCnt += recs.size();
if (done) {
System.debug('Total Count = ' + totalCnt);
} else {
System.debug('Total Count is greater than ' + totalCnt);
}
System.debug('Query Rows : ' + Limits.getQueryRows() + '/' + Limits.getLimitQueryRows());
System.debug('Queries : ' + Limits.getQueries() + '/' + Limits.getLimitQueries());
クエリ実行が100回までという制限と、OFFSETできるのは2,000件まで、というのを駆使しています。正直単なるCountよりこっちのほうがSalesforceのサーバには負荷かかってると思います。もはやガバナとか本末転倒じゃないのかって思っちゃいますよね。
まとめ
どう転んでもバッドノウハウなので、早くここにVoteしておきましょう。
でも6年も放置されてるので死んでいいと思います。