スティルハウスの書庫の書庫

はてなダイアリーで書いてた「スティルハウスの書庫」を移転してきました。

LogCounterはどうでしょう?・実装編

元旦はSlim3の練習もかねてLogCounterを実装してみました。このカウンターは、

  • ユニークな連番を生成して返す(sharding counterのように集計専用ではありません)
  • 追記ログで値を保持するのでスケールアウトする(はず)

といった特徴があります。以前のロジックをもっと簡素化してタイムスタンプなしで実装してみました:

  • MemcacheService#incrementでカウントアップし、値を返す
    • CountLogエンティティを新規追加する(以下プロパティを備える)
      • countValue: カウント値
  • memcache上に値がない場合(初期化時またはmemcache上の値の破棄時)
    • CountLogのcountValueの降順で最初のエンティティを取得し、そのvalue値をmemcacheに入れる
    • CountLogがない場合は、1をmemcacheに入れる

これをSlim3で実装したソースはこちらで公開してます。ついでにグローバルなタイムスタンプを生成するコードもコミットしてあります。

このソースの中から、ポイントだけ抜粋します。カウント値を1つ増やして返すincrementメソッドです(クラス全体はこちら)。

	/**
	 * Increments the counter value and returns it. The values will be unique
	 * serial numbers across the application. The value is generated by
	 * {@link MemcacheService#increment(Object, long)} and recorded by
	 * {@link CounterLog} entities so that the counter may have durability while
	 * it is scalable. You may need to add a cron task to delete the old
	 * CounterLog entities at background, if the entities are growing too big.
	 * 
	 * @return an incremented counter value
	 */
	public long increment() {

		// try to get the next value from Memcache
		Long countValue = null;
		try {
			countValue = mcService.increment(MC_KEY_COUNTER, 1);
		} catch (Exception e) {
			log.log(Level.WARNING, "Failed to increment on Memcache: ", e);
		}

		// if failed, restore the value from Log
		if (countValue == null) {
			countValue = restoreCountValue();
		}

		// save a countValue by a CounterLog
		try {
			saveCounterLog(countValue);
		} catch (DatastoreTimeoutException e) {
			log.log(Level.WARNING, "Failed to save CounterLog: ", e);
		}

		// return the value
		return countValue;
	}

ほぼ上記ロジックの通りに書いてます。MemcacheService#incrementメソッドから例外が出たらログ出すようにしてますが、今のところ例外を見たことはありません。

incrementメソッドで得たカウント値は、saveCounterLogメソッドで保存します:

	private void saveCounterLog(long countValue) {

		// create CounterLog
		final CounterLog cl = new CounterLog();
		cl.setCountValue(countValue);

		// save it
		final Transaction tx = Datastore.beginTransaction();
		Datastore.put(cl);
		Datastore.commit(tx);
	}

一方、incrementメソッドで加算すべきカウント値がない場合(初期化時、またはMemcacheから破棄された場合)は、以下のrestoreCountValueメソッドでDatastoreからリストアします:

	// restore the largest count value from Datastore
	private long restoreCountValue() {

		// try to find the last CountLog
		final CounterLogMeta clm = CounterLogMeta.get();
		final List<CounterLog> cls = Datastore.query(clm).sort(
				clm.countValue.desc).limit(1).asList();

		// restore count value if it can
		final long countValue;
		if (cls.isEmpty()) {
			countValue = 1;
		} else {
			countValue = cls.get(0).getCountValue() + 1;
		}

		// save the new value to Memcache
		mcService.put(MC_KEY_COUNTER, countValue);
		log.log(Level.WARNING, "Restored count value: " + countValue);

		// return the value
		return countValue;
	}

これも上述のロジック通りです。ただし、何回かテストしてますが、incrementメソッドから突然nullが返る(=memcacheから値が破棄される)という現象は確認できていません。

Kotori Web JUnit Runnerでクラウド上の単体テスト

まずはローカル環境で単体テストを書いたのですが、Memcacheの挙動などはやはりクラウド環境でもテストしないと私などは気が収まりませんw クラウド環境で単体テストってどう書こうかな〜とtwitterでつぶやいてみたら、@higayasuoさんが@bufferingsさんのKotori Web JUnit Runner(ktrwjr)を教えてくれました。お二人に感謝!

ktrwjrの使い方はとても簡単で、上記ページの手順にしたがって.jarなどを配置します。Slim3での利用時に注意すべきポイントは以下の通りです。

  • Slim3でテストクラスを記述するtestソースフォルダではなく、srcソースフォルダにテストクラスを書く(前者はローカル環境でのテスト用で、クラウド環境にはデプロイされない)
  • テストクラスはSlim3のLocalServiceTestCaseクラスを継承するのではなく、JUnitのTestCaseクラスを継承する(前者はローカル環境でのテスト用のため)

またこれらの制限があるため、今のところローカルテスト用とクラウドテスト用のテストクラスを個別に書く(コピーする)必要があります。この点は@bufferingsさんによるとまもなく改善していただけるようです。

パフォーマンスや課題

このktrwjrを使って、こんなテストコードを書いて、ローカル環境とクラウド環境の双方で通ることを確認しました。こちらのページで実際にktrwjrで単体テストをお試しいただけます。カウンタ値を取得しつづけるループを10秒間実行するテストです。

このテストでわかったことや課題:

  • JMeter等を使って多数リクエストでのテストとかしたかった
  • カウント処理は7/sくらいのスループット。ただし単体テストによる計測なので、JMeter等でクラウドを“あっためて”テストすれば、スループットはもっと上がると思います
  • ConcurrentModificationExceptionは原理的に出ないので、スケーラビリティは高いはず(MemcacheService#incrementのスケーラビリティに依存します)
  • Memcacheの値が破棄される現象は観測されなかった(なのでrestoreCountValueでのロック等は実装してません)
  • CountLogエンティティは永遠に増え続けます(cronで削除は書いてません)
  • saveCountValueはTaskQueue化すれば1ケタくらい速くなるはず(しかしカウンターごときにTQのquota消費するのもどうか)
  • CountLogにタイムスタンプを持たせるのを省略したので、カウント値をリセットする場合は、CountLogエンティティを全削除する必要がある(タイムスタンプ持たせれば全削除不要)

という感じです。フィードバックありましたらぜひお知らせください。