連打対策などりの同時アクセス禁止機構
- 2013-02-08
ゆるふわ連打対策のお時間です。連打されて無限にあーーーーーーー!という悲鳴を上げたり上げなかったりするとかしないとしても、何らかの対策したいよね!ということで、ASP.NETのお話。Application.Lock使ってSessionに、というのは複数台数あったら死ぬのでナシね(Application.Lockは当然、一台単位でのロックなので複数台数でロックは共有されてない)。そんなわけで、カジュアルな一手を打ちます。先に利用例から。
static void StandardUsage(string token)
{
// 複数サーバーで共有されるロックもどきの取得
using (var rock = DistributedLock.Acquire("StandardUsage-Lock-Token-" + token))
{
rock.ThrowIfLockAlreadyExists(); // 二重に取得された場合は即座に例外!
// 以下、本体を書けばいい
}
}
こんなふーに書けると、楽ですね。tokenは、まあ好きな単位で。ユーザー一人の単位だったら、認証済みなら何らかのIDを。非認証状態なら、POSTのHiddenにGUIDでも仕込んでおけばいい、と。ただの連打対策ってわけじゃなく、複数ユーザー間で同時処理されるのを抑えたければ、何らかのキーを、例えばソーシャルゲームだとチーム単位で、チームIDでかけたりとかします。
ロックもどきには↑の例ではMemcachedを使いました。単純に、Memcachedに指定キーでAddしにいく→Keyが既に存在していると上書きしないで追加に失敗→二重実行時は必ず失敗したという結果を受け取れる(bool:falseで)→Disposeで追加出来たときのみキーを必ず削除する(&保険でexpireもつけておく)
usingの部分は割と定型なので、毎回コントローラーを丸ごと囲むとかなら、属性作って、属性ペタッと貼るだけでOKみたいな形にするといいと思われます!
ド単純ですが、普通に機能して、結構幸せになれるかな?Memcachedならカジュアルに叩いても、相当耐えきれますから。あ、勿論、固定の台にリクエストが飛ぶの前提なのでノードがぐいぐい動的に追加削除されまくるよーな状況ではダメですよ、はい。あんまないでしょうが(Memcachedはクライアントサイドの分散で、複数台あってもキーが同一の場合は基本的に同じ台に飛ぶ)。
public class DistributedLockAlreadyExistsException : Exception
{
public DistributedLockAlreadyExistsException(string key)
: base("LockKey:" + key)
{ }
}
public class DistributedLock : IDisposable
{
static MemcachedClient client = new MemcachedClient();
static readonly TimeSpan DefaultExpire = TimeSpan.FromSeconds(5);
public bool IsAcquiredLock { get; private set; }
string key;
bool disposed;
private DistributedLock(string key, TimeSpan expire)
{
this.key = key;
this.IsAcquiredLock = client.Store(StoreMode.Add, key, DateTime.Now.Ticks, expire);
}
public static DistributedLock Acquire(string key)
{
return Acquire(key, DefaultExpire);
}
public static DistributedLock Acquire(string key, TimeSpan expire)
{
return new DistributedLock(key, expire);
}
public async Task<bool> WaitAndRetry(int retryCount, TimeSpan waitTime)
{
var count = 0;
while (count++ < retryCount && !IsAcquiredLock)
{
await Task.Delay(waitTime);
IsAcquiredLock = client.Store(StoreMode.Add, key, DateTime.Now.Ticks, DefaultExpire);
}
return IsAcquiredLock;
}
public void ThrowIfLockAlreadyExists()
{
if (!IsAcquiredLock)
{
throw new DistributedLockAlreadyExistsException(key);
}
}
public void Dispose()
{
if (!disposed && IsAcquiredLock)
{
disposed = true;
var removeSuccess = client.Remove(key);
}
GC.SuppressFinalize(this);
}
~DistributedLock()
{
Dispose();
}
}
MemcachedのライブラリはEnyimMemcachedです。
Asyncとリトライ
取得に失敗したら、間隔おいてリトライぐらいはしたいですよね、いや、連打対策なら不要ですが、そうでないように使う場合は。でも、ベタにThread.Sleepでまったりしたくないよねえ、という、そこでasyncですよ!async!
async static Task TaskUsage(string token)
{
using (var rock = DistributedLock.Acquire("TaskUsage-Lock-Token-" + token))
{
if (!rock.IsAcquiredLock)
{
// 200ミリ秒感覚で3回取得に挑戦する
await rock.WaitAndRetry(3, TimeSpan.FromMilliseconds(200));
rock.ThrowIfLockAlreadyExists(); // それでもダメなら例外投げるん
}
// 以下、本体を書けばいい!
}
}
WaitAndRetryメソッドではawait Task.Delay(waitTime)によって待機させています。少し前だとまんどくせ、と思って書く気のしない処理も、C# 5.0のお陰でカジュアルに書けるようになっていいですね。
Memcachedを立てないサーバー一台の場合
サーバー一台の場合は、わざわざMemcached立てるのも馬鹿らしいので、インメモリなキャッシュを代替として使えばいいと思われます。HttpRuntime.Cacheでも、System.Runtime.Caching.MemoryCacheでも、なんでもを、client.Storeのとこに差し替えてもらえれば。ただ、MemoryCacheは何かちょっと今回試すためにもぞもぞ弄ってたんですが、Addまわりの挙動がすんごく怪しくて信用ならない気がするので私は使うのパス。大丈夫なのかなあ。
まとめ
うーん、まんま、かつ、ゆるふわ単純な話なので特にまとめる話はないかしらん。
ので、We're Hiringということで謎社のほめぱげが少しだけリニューアル、ただしリクルートページが諸事情でまだ工事中!メールフォーム入れるつもりなので、↑のような感じにC# 5.0をすぐに振り回すような最先端な環境のC#でウェブな開発がやりたい方は、是非応募してください。相当本気で人が欲しいところですねー。現状ですけれど、リリース2週間で早くもランキング3位を獲得などと、あまり細かくは言えないのですけれど、まあ非常に好調ですので、安心して&是非とも一緒に加速させましょう。