一、七步流程
创建角色看起来只是插一行数据,实际是一条拒绝链:每一步都可能合法地拒绝请求,只有全部通过才允许落库。顺序不是风格问题,而是决定失败时系统里有没有半成品。
| 步骤 | 检查或动作 | 典型失败 |
|---|---|---|
| 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没有唯一索引,客户端重试创建出两个角色。- 把发邮件、写日志放进创建事务,外部服务抖动导致创建整体失败。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。