《游戏服务端编程实践》6.3.1 创建角色流程

从账号校验、区服与角色位检查到角色列表返回,完整拆解创建角色的七步拒绝链、request_id 幂等、名字并发抢注、角色与初始物品同事务边界,并给出可测试的 Creator 实现以及重复请求、重名、角色位已满三类失败用例,同时说明每一步的拒绝顺序与回滚策略。

一、七步流程

创建角色看起来只是插一行数据,实际是一条拒绝链:每一步都可能合法地拒绝请求,只有全部通过才允许落库。顺序不是风格问题,而是决定失败时系统里有没有半成品。

步骤检查或动作典型失败
1 账号账号存在、状态正常、未被封禁invalid_account
2 请求号request_id 非空,且未绑定其他账号request_bind
3 区服与角色位区服开放、角色数未达上限slot_full
4 名字2–12 字符、字符集、敏感词invalid_name
5 创建记录角色行、初始物品、请求映射同事务name_taken / slot_full
6 初始资产新手装备、货币、任务由同一事务写入整体回滚
7 返回列表重新查询该账号在该区服的角色—

第 2 步提前,是为了让重复提交在角色位已满时仍能拿到上次结果;否则客户端重试会从“成功”变成 slot_full。第 3、4 步都在写库之前,名字不合法就不应该占用一次数据库事务。

二、账号、区服与角色位

账号校验查的是账号服务,不是角色库:账号不存在、被冻结、被注销都应在这里拒绝,不能等到角色写入时才发现外键悬空。区服校验至少包含“区服存在、已开放、未合并下线”,角色位则按 (account_id, server_id) 统计。

角色位的检查不是安全边界。两个请求同时看到“还剩一个空位”时都会通过,最终要靠数据库唯一索引 (account_id, server_id, slot) 或行锁兜底。应用层检查用于快速失败和给出友好错误,唯一约束用于保证正确性,两者缺一不可。

三、幂等:request_id 与唯一约束

创建角色是典型的“客户端超时但服务端已成功”场景。客户端重发时不能创建第二个角色,也不能报错让玩家以为失败。

层次作用失效时
应用层 RoleByRequest快速返回上一次结果并发窗口内仍可能重复
request_id 唯一索引数据库层拦截重复插入无
名字唯一索引拦截重名无
(account_id, server_id, slot) 唯一索引拦截抢位无

request_id 必须和账号绑定:同一个请求号被另一个账号复用,应该返回 request_bind 而不是泄露原角色。存储接口约定,如果插入时撞到 request_id 唯一键,就查出已有角色并返回,让重复请求表现为成功。

四、名字校验与并发抢注

长度按 Unicode 码点计算,不按字节:中文名两个字符是合法的,UTF-8 下却是六个字节。字符集只放字母、数字和下划线,避免空格、控制字符和视觉混淆字符。敏感词在服务端做归一化后匹配,客户端提示只能作为体验补充。

全服唯一通常按“区服 + 小写名字”建唯一索引。检查与插入之间一定有窗口,所以不要采用“先查再插”作为唯一手段:两个玩家同时查都显示可用,随后一个插入成功、一个收到唯一键冲突。捕获冲突后返回 name_taken,让客户端换名即可。

五、事务边界

角色行、初始物品、初始任务、request_id 映射、名字占用必须在一个事务里。任何一步失败都整体回滚,不能出现“角色建好了但背包是空的”。

发欢迎邮件、写审计日志、推送数据分析可以放到事务提交之后。它们失败不应该回滚角色,但也不能静默丢失:用 outbox 表在同一个事务里记一条待发送事件,再由异步任务投递。直接在事务里调用邮件服务,会把外部超时变成创建角色失败。

六、失败场景:初始物品写失败

最典型的半成品是角色已经插入,发初始物品时数据库连接超时,代码只记录了错误日志。玩家登录后看到一个没有新手装备的角色,客服只能手工补发;如果补发脚本不幂等,还会重复发放。

正确做法是让初始物品与角色同事务。物品模板在内存里生成,写入时使用 (role_id, item_id) 唯一键,重试不会重复。若使用分库分表,角色与背包必须落在同一分片,否则本地事务无法覆盖,需要改用补偿或 saga,并在创建流程里明确失败回滚策略。

七、Go 实现

Creator 用接口抽象账号校验和存储,流程本身只负责顺序与错误映射;CreateRole 的实现必须在同一事务内完成角色、物品、请求映射和名字索引的写入。

// file: ch06/save/create.go
// tier: L1
// verify: go test ./ch06/save/

package save

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"
	"unicode"
	"unicode/utf8"
)

var (
	ErrInvalidAccount = errors.New("invalid account")
	ErrInvalidServer  = errors.New("invalid server")
	ErrInvalidName    = errors.New("invalid role name")
	ErrNameTaken      = errors.New("role name already taken")
	ErrSlotFull       = errors.New("role slot full")
	ErrRequestMissing = errors.New("request id required")
	ErrRequestBind    = errors.New("request id bound to another account")
)

type Role struct {
	ID        uint64
	AccountID uint64
	ServerID  int32
	Slot      int
	Name      string
	Level     int
	CreatedAt time.Time
}

type Item struct {
	RoleID uint64
	ItemID int32
	Count  int32
	Bound  bool
}

type CreateRequest struct {
	RequestID string
	AccountID uint64
	ServerID  int32
	Name      string
}

type AccountChecker interface {
	Active(ctx context.Context, accountID uint64) (bool, error)
}

// CreateRole 必须在同一事务内写角色、初始物品、request_id 映射和名字唯一索引。
type CreatorStore interface {
	RoleByRequest(ctx context.Context, requestID string) (Role, bool, error)
	CreateRole(ctx context.Context, requestID string, role *Role, items []Item) (*Role, error)
	RolesByAccount(ctx context.Context, accountID uint64, serverID int32) ([]Role, error)
}

type Creator struct {
	store    CreatorStore
	accounts AccountChecker
	banned   []string
	maxSlots int
	now      func() time.Time
	onCommit func(Role)
}

func NewCreator(store CreatorStore, accounts AccountChecker, maxSlots int, banned []string) *Creator {
	if maxSlots <= 0 {
		maxSlots = 6
	}
	return &Creator{store: store, accounts: accounts, banned: banned, maxSlots: maxSlots, now: time.Now}
}

func (c *Creator) Create(ctx context.Context, req CreateRequest) ([]Role, error) {
	if req.RequestID == "" {
		return nil, ErrRequestMissing
	}
	if req.AccountID == 0 {
		return nil, ErrInvalidAccount
	}
	if req.ServerID <= 0 {
		return nil, ErrInvalidServer
	}
	ok, err := c.accounts.Active(ctx, req.AccountID)
	if err != nil {
		return nil, fmt.Errorf("check account: %w", err)
	}
	if !ok {
		return nil, ErrInvalidAccount
	}
	existing, found, err := c.store.RoleByRequest(ctx, req.RequestID)
	if err != nil {
		return nil, fmt.Errorf("lookup request: %w", err)
	}
	if found {
		if existing.AccountID != req.AccountID || existing.ServerID != req.ServerID {
			return nil, ErrRequestBind
		}
		return c.store.RolesByAccount(ctx, req.AccountID, req.ServerID)
	}
	roles, err := c.store.RolesByAccount(ctx, req.AccountID, req.ServerID)
	if err != nil {
		return nil, fmt.Errorf("list roles: %w", err)
	}
	if len(roles) >= c.maxSlots {
		return nil, ErrSlotFull
	}
	name := strings.TrimSpace(req.Name)
	if err := ValidateName(name, c.banned); err != nil {
		return nil, err
	}
	// 槽位取当前数量,唯一索引 (account_id, server_id, slot) 负责并发兜底。
	role := &Role{AccountID: req.AccountID, ServerID: req.ServerID, Slot: len(roles), Name: name, Level: 1, CreatedAt: c.now()}
	created, err := c.store.CreateRole(ctx, req.RequestID, role, StarterItems(0))
	if err != nil {
		return nil, err
	}
	if c.onCommit != nil {
		c.onCommit(*created)
	}
	return c.store.RolesByAccount(ctx, req.AccountID, req.ServerID)
}

func ValidateName(name string, banned []string) error {
	n := utf8.RuneCountInString(name)
	if n < 2 || n > 12 {
		return fmt.Errorf("%w: length must be 2-12 runes", ErrInvalidName)
	}
	for _, r := range name {
		if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' {
			return fmt.Errorf("%w: illegal character %q", ErrInvalidName, r)
		}
	}
	lower := strings.ToLower(name)
	for _, word := range banned {
		if word = strings.ToLower(strings.TrimSpace(word)); word != "" && strings.Contains(lower, word) {
			return fmt.Errorf("%w: contains banned word", ErrInvalidName)
		}
	}
	return nil
}

func StarterItems(roleID uint64) []Item {
	return []Item{{RoleID: roleID, ItemID: 1001, Count: 1, Bound: true}, {RoleID: roleID, ItemID: 2001, Count: 10}}
}

测试覆盖重复请求只创建一个角色、大小写重名、角色位已满、账号无效和敏感词:

// file: ch06/save/create_test.go
// tier: L1
// verify: go test ./ch06/save/

package save

import (
	"context"
	"errors"
	"strconv"
	"strings"
	"sync"
	"testing"
	"time"
)

type fakeAccounts map[uint64]bool

func (f fakeAccounts) Active(_ context.Context, id uint64) (bool, error) { return f[id], nil }

type fakeCreatorStore struct {
	mu        sync.Mutex
	nextID    uint64
	byRequest map[string]Role
	roles     []Role
	names     map[string]bool
	items     map[uint64][]Item
}

func newFakeCreatorStore() *fakeCreatorStore {
	return &fakeCreatorStore{byRequest: map[string]Role{}, names: map[string]bool{}, items: map[uint64][]Item{}}
}

func (f *fakeCreatorStore) RoleByRequest(_ context.Context, id string) (Role, bool, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	role, ok := f.byRequest[id]
	return role, ok, nil
}

func (f *fakeCreatorStore) CreateRole(_ context.Context, id string, role *Role, items []Item) (*Role, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	key := strconv.Itoa(int(role.ServerID)) + "|" + strings.ToLower(role.Name)
	if f.names[key] {
		return nil, ErrNameTaken
	}
	for _, other := range f.roles {
		if other.AccountID == role.AccountID && other.ServerID == role.ServerID && other.Slot == role.Slot {
			return nil, ErrSlotFull
		}
	}
	f.nextID++
	role.ID = f.nextID
	f.byRequest[id] = *role
	f.names[key] = true
	f.roles = append(f.roles, *role)
	for i := range items {
		items[i].RoleID = role.ID
	}
	f.items[role.ID] = items
	return role, nil
}

func (f *fakeCreatorStore) RolesByAccount(_ context.Context, accountID uint64, serverID int32) ([]Role, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	var out []Role
	for _, role := range f.roles {
		if role.AccountID == accountID && role.ServerID == serverID {
			out = append(out, role)
		}
	}
	return out, nil
}

func newTestCreator(slots int) (*Creator, *fakeCreatorStore, *int) {
	store := newFakeCreatorStore()
	creator := NewCreator(store, fakeAccounts{1: true, 2: true}, slots, []string{"admin"})
	creator.now = func() time.Time { return time.Date(2026, 11, 4, 1, 58, 0, 0, time.UTC) }
	commits := new(int)
	creator.onCommit = func(Role) { *commits++ }
	return creator, store, commits
}

func createReq(id string, account uint64, name string) CreateRequest {
	return CreateRequest{RequestID: id, AccountID: account, ServerID: 1001, Name: name}
}

func TestCreateIsIdempotent(t *testing.T) {
	creator, store, commits := newTestCreator(4)
	first, err := creator.Create(context.Background(), createReq("req-1", 1, "夜风"))
	if err != nil || len(first) != 1 {
		t.Fatalf("first create: %v roles=%d", err, len(first))
	}
	second, err := creator.Create(context.Background(), createReq("req-1", 1, "夜风"))
	if err != nil || len(second) != 1 || second[0].ID != first[0].ID {
		t.Fatalf("retry: %v roles=%+v", err, second)
	}
	if *commits != 1 || len(store.items[first[0].ID]) != 2 {
		t.Fatalf("commits=%d items=%d", *commits, len(store.items[first[0].ID]))
	}
}

func TestCreateRejectsConflicts(t *testing.T) {
	creator, _, _ := newTestCreator(1)
	ctx := context.Background()
	if _, err := creator.Create(ctx, createReq("req-1", 1, "Nova")); err != nil {
		t.Fatal(err)
	}
	if _, err := creator.Create(ctx, createReq("req-2", 2, "nova")); !errors.Is(err, ErrNameTaken) {
		t.Fatalf("case-insensitive name: %v", err)
	}
	if _, err := creator.Create(ctx, createReq("req-3", 1, "夜风")); !errors.Is(err, ErrSlotFull) {
		t.Fatalf("slot full: %v", err)
	}
	if _, err := creator.Create(ctx, createReq("req-4", 99, "夜风")); !errors.Is(err, ErrInvalidAccount) {
		t.Fatalf("inactive account: %v", err)
	}
	if _, err := creator.Create(ctx, createReq("req-5", 2, "admin01")); !errors.Is(err, ErrInvalidName) {
		t.Fatalf("banned name: %v", err)
	}
}

八、运行测试

cd .local/checks/gs-practice-lab && gofmt -l ch06/save && go vet ./ch06/save/ && go test ./ch06/save/ -count=1
ok  	gspractice/ch06/save	0.115s

小结

创建角色的正确性来自顺序和约束:账号、请求号、角色位、名字逐层拒绝,真正写库时用一个事务覆盖角色与初始资产;幂等靠 request_id 与唯一索引双保险,而不是靠应用层“先查一下”。应用层检查负责体验,数据库约束负责正确性。

常见错误与边界

  • 先建角色再校验名字,重名时留下孤儿角色或被迫删除已发放物品。
  • 初始物品失败但角色已提交,玩家拿到空背包,补发脚本又不幂等。
  • 只靠“先查名字再插入”,并发抢注时两个请求都通过检查。
  • request_id 没有唯一索引,客户端重试创建出两个角色。
  • 把发邮件、写日志放进创建事务,外部服务抖动导致创建整体失败。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist