Go言語でAsynqにレート制限を実装する

タスクキューは多くのWebアプリケーションで必要不可欠な機能ですが、大量のタスクを処理する際にはレート制限が重要になります。特に外部API呼び出しやメール送信などのタスクでは、適切なレート制限なしに処理すると、サービス制限に引っかかったり、相手先のサーバーに負荷をかけてしまう可能性があります。

今回は、Go言語の人気タスクキューライブラリであるAsynqにレート制限を実装する方法を、複数のアプローチとともに解説します。

プロジェクト構成

このサンプルプロジェクトは以下の構成になっています。

1
2
3
4
5
6
7
8
9
10
11
12
13
asynq-late-limiter/
├── go.mod
├── tasks/
│ └── tasks.go # タスクの定義と処理ロジック
├── limiter/
│ ├── limiter.go # Limiterインターフェース
│ ├── rate_limiter.go # golang.org/x/time/rateを使った実装
│ ├── redis_rate_limiter.go # Redisベースの実装(シンプル版)
│ ├── redis_rate_limiterv2.go # Redisベースの実装(改良版)
├── producer/
│ └── main.go # タスクを生成するプロデューサー
└── worker/
└── main.go # タスクを処理するワーカー

1. インターフェース設計

まず、レート制限の基盤となるインターフェースを定義します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// limiter/limiter.go
package limiter

import (
"fmt"
"time"
)

// レート制限を確認し、エラーを返す
type Limiter interface {
Check() error
}

// RateLimitErrorは、レート制限が超過されたときに発生するエラー
type RateLimitError struct {
RetryIn time.Duration
}

func (e *RateLimitError) Error() string {
return fmt.Sprintf("rate limited (retry in %v)", e.RetryIn)
}

このインターフェース設計により、異なるレート制限の実装を簡単に差し替えることができます。

2. レート制限の実装方式

2.1 golang.org/x/time/rateを使った実装

標準的なトークンバケットアルゴリズムを使った実装です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// limiter/rate_limiter.go
type RateLimiter struct {
limiter *rate.Limiter
limit int // 1秒間に処理できるリクエスト数
burst int // 瞬間的に許可される最大リクエスト数
retryIn time.Duration // レート制限時の再試行待機時間
}

func NewRateLimiter(limit int, burst int, retryIn time.Duration) *RateLimiter {
return &RateLimiter{
limiter: rate.NewLimiter(rate.Limit(limit), burst),
limit: limit,
burst: burst,
retryIn: retryIn,
}
}

func (rl *RateLimiter) Check() error {
if !rl.limiter.Allow() {
return &RateLimitError{
RetryIn: rl.retryIn,
}
}
return nil
}

メリット

  • 実装が簡単
  • メモリ効率が良い
  • 高速

デメリット

  • 単一プロセスでのみ動作
  • 分散環境では使用できない

2.2 Redisベースの実装(改良版)

複数のワーカープロセス間でレート制限を共有する場合に適しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// limiter/redis_rate_limiterv2.go
type RedisRateLimiterV2 struct {
client *redis.Client
key string
limit int
windowSize time.Duration
}

func (r *RedisRateLimiterV2) Allow(taskType string) (bool, int64, error) {
ctx := context.Background()
key := fmt.Sprintf("rate_limit:%s", taskType)
now := time.Now().Unix()

script := `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

-- 古いエントリを削除
redis.call("ZREMRANGEBYSCORE", key, "-inf", now - window)

-- 現在のリクエスト数を取得
local count = redis.call("ZCOUNT", key, "-inf", "+inf")

if count >= limit then
-- 最も古いリクエストのタイムスタンプを取得
local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")
local retryIn = 0.0
if #oldest > 0 then
retryIn = math.max(0, oldest[2] + window - now)
end
return {0, retryIn}
end

-- 新しいリクエストを追加
redis.call("ZADD", key, now, tostring(now) .. "-" .. redis.call("INCR", "request_counter"))
redis.call("EXPIRE", key, window)

return {1, 0}
`

result, err := r.client.Eval(ctx, script, []string{key}, r.limit, int(r.windowSize.Seconds()), now).Result()
// ... エラーハンドリングと結果の解析
}

メリット

  • 分散環境での動作
  • 複数のワーカー間でレート制限を共有
  • 正確な待機時間の算出

デメリット

  • Redisへの依存
  • わずかなパフォーマンスオーバーヘッド

3. Asynqとの統合

タスクの定義

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// tasks/tasks.go
func EmailNotificationTask(ctx context.Context, t *asynq.Task, limit limiter.Limiter) error {
// レート制限を確認
if err := limit.Check(); err != nil {
return err
}

// メール送信処理
var payload map[string]string
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
return err
}
log.Println("Sending Email to:", payload["email"], "with subject:", payload["subject"])
return nil
}

ワーカーの設定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// worker/main.go
func main() {
// 1秒間1リクエストに制限
emailRateLimiter := limiter.NewRedisRateLimiterV2("email", 1, time.Second)

srv := asynq.NewServer(
asynq.RedisClientOpt{Addr: ":6379"},
asynq.Config{
Concurrency: 5,
Queues: map[string]int{
"default": 7,
"email": 3,
},
// レート制限エラーを失敗としてカウントしない
IsFailure: func(err error) bool {
return !IsRateLimitError(err)
},
// 再試行の間隔を指定
RetryDelayFunc: retryDelay,
DelayedTaskCheckInterval: 100 * time.Millisecond,
},
)

mux := asynq.NewServeMux()
mux.HandleFunc(tasks.TypeEmailTask, func(ctx context.Context, t *asynq.Task) error {
// TaskにRateLimiterを設定する
return tasks.EmailNotificationTask(ctx, t, emailRateLimiter)
})

if err := srv.Run(mux); err != nil {
log.Fatalf("サーバーを起動できませんでした: %v", err)
}
}

エラーハンドリングとリトライ戦略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// レート制限エラーの判定
func IsRateLimitError(err error) bool {
_, ok := err.(*limiter.RateLimitError)
return ok
}

// カスタムリトライ遅延
func retryDelay(n int, err error, task *asynq.Task) time.Duration {
var ratelimitErr *limiter.RateLimitError
if errors.As(err, &ratelimitErr) {
return ratelimitErr.RetryIn
}
return asynq.DefaultRetryDelayFunc(n, err, task)
}

4. 実行方法

1. 依存関係のインストール

1
go mod tidy

2. Redisの起動

1
2
3
4
5
# Dockerを使用する場合
docker run -d -p 6380:6379 redis:latest

# または直接実行
redis-server --port 6380

3. ワーカーの起動

1
go run worker/main.go

4. タスクの投入

別のターミナルで以下のコマンドを実行。

1
go run producer/main.go

5. 実装のポイント

レート制限の適切な設定

  • 外部API呼び出し: APIの制限に合わせて設定(例:100リクエスト/分)
  • メール送信: メール送信サービスの制限に合わせて設定(例:10通/秒)
  • データベース操作: データベースの負荷を考慮して設定

エラーハンドリング

1
2
3
4
5
6
7
8
9
10
11
srv := asynq.NewServer(
redisOpt,
asynq.Config{
// レート制限エラーは失敗としてカウントしない
IsFailure: func(err error) bool {
return !IsRateLimitError(err)
},
// レート制限エラーの場合は適切な待機時間でリトライ
RetryDelayFunc: retryDelay,
},
)

まとめ

このサンプルコードでは、Asynqにレート制限を実装する複数の方法を紹介しました。どの実装を選ぶかは、システムの要件によって決まります:

  • 単一プロセス環境: golang.org/x/time/rateベースの実装
  • 分散環境: Redisベースの実装

インターフェースベースの設計により、後から実装を切り替えることも容易です。適切なレート制限により、安定したタスク処理システムを構築できます。

参考リンク

Go 1.24.3アップデート後のビルドエラーと解決方法

はじめに

最近Go 1.24.3へアップデートしたところ、ローカル環境でビルドエラーが発生するようになりました。この記事では、発生した問題と解決方法について共有します。

発生した問題

Go 1.24.3にアップデートした後、以下のようなエラーメッセージが表示されるようになりました。

1
link: duplicated definition of symbol dlopen, from github.com/ebitengine/purego and github.com/ebitengine/purego (exit status 1)

原因

調査の結果、このエラーはMacのIntel CPU(darwin/amd64)環境で発生する既知の問題であることがわかりました。Go公式リポジトリでも同様の問題が報告されています。

Github Issue #73617: cmd/link: Go 1.24.3 and 1.23.9 regression - duplicated definition of symbol dlopen

解決方法

この問題はgithub.com/ebitengine/puregoの最新バージョン(v0.8.3)で修正されています。以下のコマンドでpuregoをアップデートすることで解決できます。

1
go get github.com/ebitengine/purego@v0.8.3

アップデート後、ビルドが正常に完了することを確認しました。

まとめ

Go言語のバージョンアップ後にはライブラリとの互換性の問題が発生することがあります。特にGoのマイナーバージョンアップデート(1.24.2→1.24.3など)でも互換性の問題が生じる可能性があるため、ビルドエラーが発生した場合は依存ライブラリの更新も検討するとよいでしょう。

参考リンク

Goのエラーハンドリングのベストプラクティス

はじめに

Goのエラーハンドリングは他の言語と少し異なるアプローチを取ります。例外機構を持たないGoでは、エラーは値として扱われ、関数の戻り値として明示的に返されます。このシンプルなアプローチは強力ですが、効果的に活用するにはいくつかのベストプラクティスを理解する必要があります。

この記事では、Goのエラーハンドリングの基本から、カスタムエラーの作成、エラーのラッピングとアンラッピング、そしてerrors.Is()errors.As()を使用した効果的なエラー判定の方法について解説します。

Goのエラーハンドリングのベストプラクティス

Goのエラーハンドリングのベストプラクティスを理解するために、いくつかの具体例を見ていきましょう。

1. 基本的なエラー定義と判定

Goでは、標準パッケージのerrorsを使って簡単にエラーを定義できます。以下のサンプルコードでは、よく使われるパターンを示しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package main

import (
"errors"
"fmt"
)

// カスタムエラー型の定義
var (
ErrNotFound = errors.New("not found")
ErrInvalid = errors.New("invalid input")
)

// データベース操作を模擬する関数
func findUser(id string) error {
// エラーをラップして返す
return fmt.Errorf("failed to find user: %w", ErrNotFound)
}

type NotFoundError struct {
ID string
}

func (e *NotFoundError) Error() string {
return fmt.Sprintf("user %s not found", e.ID)
}

func main() {
// 悪い例: == 演算子での比較
err := findUser("123")
if err == ErrNotFound { // これは動作しない!
fmt.Println("== で比較。ユーザーが見つかりませんでした")
}

// 良い例1: errors.Is() を使用
err = findUser("123")
if errors.Is(err, ErrNotFound) {
fmt.Println("errors.Is() で比較。ユーザーが見つかりませんでした")
}

err = &NotFoundError{ID: "123"}
var notFoundErr *NotFoundError
if errors.As(err, &notFoundErr) {
fmt.Printf("ユーザー %s が見つかりませんでした\n", notFoundErr.ID)
}

// エラーのラップと展開の例
err = findUser("123")
fmt.Printf("元のエラー: %v\n", err)

// ラップされたエラーを展開
unwrapped := errors.Unwrap(err)
fmt.Printf("展開されたエラー: %v\n", unwrapped)
}

実行結果

1
2
3
4
errors.Is() で比較。ユーザーが見つかりませんでした
ユーザー 123 が見つかりませんでした
元のエラー: failed to find user: not found
展開されたエラー: not found

2. エラー判定の種類と使い分け

上記のコードには、エラー判定のための重要な方法がいくつか含まれています。それぞれを詳しく解説します。

エラー比較の落とし穴: == 演算子

1
2
3
4
5
// 悪い例: == 演算子での比較
err := findUser("123")
if err == ErrNotFound { // これは動作しない!
fmt.Println("== で比較。ユーザーが見つかりませんでした")
}

このコードでは、findUser関数が返すエラーとErrNotFound==演算子で直接比較しています。しかし、このアプローチには大きな問題があります。findUser関数は単純にErrNotFoundを返すのではなく、fmt.Errorf("failed to find user: %w", ErrNotFound)を使ってエラーをラップしているため、==比較は失敗します。ラップされたエラーは元のエラーと等価ではないからです。

推奨方法1: errors.Is()

1
2
3
4
5
// 良い例1: errors.Is() を使用
err = findUser("123")
if errors.Is(err, ErrNotFound) {
fmt.Println("errors.Is() で比較。ユーザーが見つかりませんでした")
}

Go 1.13以降で導入されたerrors.Is()関数は、エラーチェーン内のどこかに特定のエラー値が含まれているかを確認します。これにより、ラップされたエラーでも正しく比較できるようになります。errors.Is()は、エラーが同一かどうかを確認する際の推奨方法です。

推奨方法2: errors.As()

1
2
3
4
5
err = &NotFoundError{ID: "123"}
var notFoundErr *NotFoundError
if errors.As(err, &notFoundErr) {
fmt.Printf("ユーザー %s が見つかりませんでした\n", notFoundErr.ID)
}

errors.As()関数は、エラーチェーン内のいずれかのエラーが特定の型に一致するかを確認し、一致する場合はその値をターゲット変数に設定します。これは、エラーの型に基づいて処理を分岐させたい場合や、エラー内の追加情報(この例ではID)にアクセスしたい場合に特に有用です。

3. エラーのラッピングとアンラッピング

1
2
3
4
5
6
7
// エラーのラップと展開の例
err = findUser("123")
fmt.Printf("元のエラー: %v\n", err)

// ラップされたエラーを展開
unwrapped := errors.Unwrap(err)
fmt.Printf("展開されたエラー: %v\n", unwrapped)

Go 1.13では、%w動詞を使用して元のエラーをラップする機能がfmt.Errorfに追加されました。これにより、より詳細なコンテキスト情報を提供しながら、元のエラー値を保持できます。errors.Unwrap()関数を使用すると、ラップされたエラーから元のエラーを取り出すことができます。

4. カスタムエラー型の作成

1
2
3
4
5
6
7
type NotFoundError struct {
ID string
}

func (e *NotFoundError) Error() string {
return fmt.Sprintf("user %s not found", e.ID)
}

Goでは、Error()メソッドを実装した任意の型をエラーとして使用できます。カスタムエラー型を作成することで、エラーに追加情報(この例ではID)を含めることができ、より詳細なエラーハンドリングが可能になります。

まとめ

Goのエラー判定では、==演算子による比較はラップされたエラーに対して正しく動作しません。
そのため、Go1.13以降で導入されたerrors.Iserrors.Asを活用することで、より安全かつ柔軟なエラーハンドリングが可能になります。
エラー処理の品質向上のため、ぜひこれらの手法を取り入れてみてください。

メルカリを退職してフリーランスになりました

自己紹介とメルカリでの経験

私は 16 年ほど IT 企業でエンジニアとして働いてきました。GMO ペパボといった業界大手のサービスを運営している会社、ヤフーやメルカリといったメガベンチャー企業に勤めていました。直近はメルカリでおおよそ 3 年間働き、主に社内のカスタマーサービス部門が使うツールの開発に携わっていました。その中で GO 言語での開発経験や開発チームをマネジメントする経験を積むことができました。

フリーランスになろうと思った理由

正社員としてのキャリアアップに魅力を感じなくなったからです。

私は物作りが好きで、現場で手を動かすのが性に合っていると思っています。また、仕事は好きですが、毎日深夜まで働いたり会社の評価を意識して振る舞うことなどは避けたいと思っています。

一方で正社員として求められる仕事はマネジメント業務にシフトしていきました。また、その会社の社員として期待される振る舞いを求められるようになりました。

上記のような自分の特性と会社が求めることのギャップがストレスとなり、正社員としてキャリアアップしていくことにモチベーションを感じられなくなってしまいました。

退職前に準備したこと

フリーランスとして独立する前に、以下の準備を行いました。

  1. スキルの棚卸し
    • 職務経歴書の作成を通して、自分のスキルの棚卸しを行いました
  2. 副業
    • 実際に IT 企業と業務委託契約を結び、平日の夜や週末に働いていました
    • 個人事業主として登録し、青色確定申告の書類を作成していました
  3. 案件探しと情報収集
    • フリーランスの案件を扱うエージェントに登録して実際に案件獲得を行いました
    • 自分の単価やどういった需要があるのかを確認することが目的です

フリーランスになってからのリアル

まだ 1 ヶ月程度ですが、実際にフリーランスとして活動し始めて、以下のようなことを感じました。

アップサイド

  • 時間と場所の自由
    • フルリモート、フルフレックスで働いているため、仕事と生活のバランスがとりやすい
    • 案件はフルリモート前提の仕事のみに限定することができる
    • 同じ現場のフリーランスの方は、週4勤務だったり15時30分で退勤するように業務時間を調整していたりと柔軟に働いている
  • 開発に集中できストレスが減る
    • 業務範囲が明確なので、本業である開発の仕事に集中することができる
    • 雑務やマネジメント業務をする必要がないため、余計なストレスが減った
    • 職場の飲み会に参加しなくてよい
  • 環境を自分で選ぶことができる
    • 勢いのあるスタートアップの案件に携われば、事業の成長を感じることができる
    • 携わりたい事業があればその企業にアプローチすることができる
    • 自分に合わないと感じるところからは撤退する判断ができる

ダウンサイド

  • 雇用と収入の不安定さ
    • 会社員のように毎月決まった給料が入ることが確約されていない
    • 常に契約解除のリスクがある
    • 有給休暇がないので休んだ分だけ収入が減る
  • 社会保障が脆弱
    • フリーランスは将来受け取れる年金額が少ない
    • 病気になったり働けなくなったりした時の収入の保証がない

ダウンサイドについては、リスク対策が可能です。しかし、こういったリスク対策についても全て自分自身で行う必要があります。

フリーランスの実態と心境

私の場合は正社員の頃とあまり働き方は変わっていません。平日の 8 時間は仕事、コミュニケーションは毎日発生しますし、仕事の内容や質も正社員に求められるものと変わりません。

しかし、契約形態としては独立しているので、自分自身のアイデンティティを大事にできる点が精神的なメリットになっている感じています。

その分、不安定さはありますが、常に安泰ではないという危機感が自分にとってよい刺激になっています。

これからフリーランスを続けていくと、様々なトラブルに遭遇すると思います。それも勉強だと思って邁進していく所存です。

これからフリーランスを目指す人へのアドバイス

最後に私の経験から、フリーランスを目指す人に伝えたいことは以下の 3 つです。

  1. 準備と情報収集をする
    • いきなりフリーランスになることは不安も多いと思います。そういった場合は、副業から始めてみることをおすすめします
    • 現在は副業案件が多いそうです。私もエージェントから副業案件についての提案をよく受けます
    • 副業をすると確定申告が必要になるため、経理の知識もつきます。青色確定申告書類を作成できるようになりましょう。節税になります
  2. 自分の強みの把握とスキルの研鑽
    • 自分の強みを明確にするために職務経歴書を作成することをおすすめします
    • 職務経歴書のアピールになるような経験を積めるように、日頃から意識してスキルの研鑽に励みましょう
  3. 家族の理解が大事
    • 家族がいる人は家族の理解が必要です。収入が安定している正社員から収入が確約されていないフリーランスに転向することは心理的な障壁が高いからです
    • いきなり説得することが難しい場合は、時間をかけて理解してもらうことが必要です。私は 3 年ほど時間がかかりました

エンジニアを目指す人が最初にすべきこと、するべきでないこと

近年、エンジニアを目指す人が増えてきています。今までパソコンを触ってきていない人もエンジニアを目指し始めており、史上空前のエンジニアブームといってもよいでしょう。この記事では、これからエンジニアを目指す人向けに何をすべきか、何をすべきでないかを説明していきたいと思います。

エンジニアを目指す人が増えている背景

近年、エンジニア需要の増加 *1 でエンジニア(システムエンジニア、プログラマーのこと)を目指す人が増えてきています。文系の大学生、主婦、営業部で働いているサラリーマンなど、今までパソコンに触れてきていない人もエンジニアに興味を持ち目指している、といった状況です。

そのような状況はTwitterの「#駆け出しエンジニアとつながりたい」のようなタグからも垣間見ることができます。

また、SNSの発展に伴いインフルエンサーの影響も年々大きくなってきています。一部のインフルエンサーが発信する、好きな時に好きな場所で働く自由な働き方*2、高収入(フリーランスで年収1000万円)*3などのキラキラしたイメージがエンジニア人気にさらに拍車をかけています。

プログラミングについては興味がないが、働き方や高収入に魅力を感じている人が多い印象です。

穴の空いたバケツに水を注ぐな

これからエンジニアを目指す人へのアドバイスは「穴の空いたバケツに水を注ぐな」です。

プログラミングの学習を始めた人の話しを聞いてみると、インフルエンサーが発信している自由な働き方、高収入に魅力を感じ、フリーランスを目指してプログラミングの学習を始める方が多い印象です。

学習を始めることは大変素晴らしいことなのですが、プログラミングに関する学習をしてきていない人たちの多くはパソコンの操作がままなりませんので、ファイルを作成したり、画像をダウンロードしたりといった、パソコンに慣れている人だったら難なく行える操作ができません。

私はこのようなパソコンの操作がままならない状態を穴の空いたバケツ状態と呼んでいます。穴の空いたバケツにいくら水を入れても何も残らないように、この状態でいろいろと教えても多くの知識は定着しません。

残念ながらこの状態で高額な教材を購入したり、高額な学費を払ってプログラミングスクールに通っている人がいます。しかし、この投資は割りに合わない可能性が高いです。

穴の空いたバケツに多額の資金を投資することは避けましょう。
穴の空いたバケツ

穴の空いたバケツに水を入れてもほとんどが流れていってしまう

エンジニアを目指す人が最初にすべきこと

エンジニアを目指す人が最初にすべきことは、穴の空いたバケツをふさぐことです。

まずは次の2点をおさえます。

  • 学習する下地をつくること
  • プログラミングが自分にあっているかを確認すること
この2つが整うまではお金をかけてはいけません。無駄な投資になってしまうリスクがあるからです。

学習する下地をつくる

どうやって学習する下地をつくるかですが、HTML/CSSの学習からはじめましょう。

最初からPHPなどのプログラミング言語を学習した方が効率がいいというインフルエンサーもいますが*4、パソコンに慣れていない人はプログラミング言語をインストールしたり、実行したりすることが難しいので、そこで詰みます。

また、ターミナルやブラウザーに表示されているプログラムの実行結果をみて、楽しいと感じる人はあまりいないでしょう。それよりも、華やかなHTML/CSSの画面をみる方がやりがいを感じられるものです。

パソコンの操作に慣れていない人はHTML/CSSから始めましょう。具体的な方法については、別の記事で説明したいと思います。

プログラミングが自分にあっているのかを確認する

最初の段階で必要なのは、パソコンの操作に慣れつつプログラミングが自分にあっているのかを確認することです。特にプログラミングの学習が楽しいと感じられるかどうかが重要です。

高額な費用を払った後にプログラミングは自分にはあっていなかったと知る人もいます。自分にあっているかどうかは数十万円の学費を払わずとも、数千円の入門書を買って1カ月くらい学習をやってみればわかります

プログラミング学習を楽しいと感じることができればOKです。

参考リンク

  1. IT 人材需給に関する調査
  2. 【月収800万】海外フリーランスの1日ルーティン【マレーシア】
  3. WEBフリーランスで年収1000万円超えないのはどう考えてもおかしい
  4. 【プログラミング初心者必見】HTML/CSSから勉強し始めてはいけない理由