取消订单与处理并发

创建和查询之后,订单开始发生变化。用户可能连点两次取消,多个请求也可能同时到达。我们先说清想要的结果,再看代码如何守住这个约定。

本章内容

先取消一笔新订单,再取消一次

使用上一章的服务即可;如果没有运行,在工程目录执行 go run ./cmd/orders -db lesson.db -tcp 127.0.0.1:8088。在另一个终端创建一笔新订单,确认返回的状态是 pending。Windows 请使用 curl.exe。

创建一笔供本章使用的订单
curl -i -X POST http://127.0.0.1:8080/orders -H "Content-Type: application/json" --data-binary "@requests/create.json"

把下面的 ORDER_ID 换成刚才返回的完整 ID。连续执行两次取消,最后查回:

重复的是同一笔订单的取消,不是重复创建
curl -i -X POST http://127.0.0.1:8080/orders/ORDER_ID/cancel
curl -i -X POST http://127.0.0.1:8080/orders/ORDER_ID/cancel
curl -i http://127.0.0.1:8080/orders/ORDER_ID
要核对的结果
第一次取消 → 200,status 从 pending 变为 cancelled
第二次取消 → 200,订单内容与第一次完全相同
再次查询   → 200,status 仍为 cancelled
ID、SKU、数量、created_at 都没有因取消而改变

重复取消得到相同结果,叫做取消操作的幂等性。这是我们为这项操作制定的约定,不是使用 POST 或使用 KPro 自动得到的保证。本例重复创建仍会生成两笔不同订单。

把产品约定写成状态变化

当前情况取消操作的结果
pending保存 cancelled,返回修改后的订单
cancelled保持 cancelled,仍返回订单
没有这个 IDErrNotFound,HTTP 入口返回 404
其他状态ErrConflict;当前正常流程不会创建这样的状态
internal/order/service.go节选 · L73
func (s *Service) Cancel(ctx context.Context, id string) (Order, error) {
	return s.store.UpdateStatus(ctx, id, func(status string) (string, error) {
		switch status {
		case Pending:
			return Cancelled, nil
		case Cancelled:
			// A retry returns the same state: cancelling twice is harmless.
			return Cancelled, nil
		default:
			return "", ErrConflict
		}
	})
}

Service 传给 Store 一个函数:“你拿到当前状态后,按这些规则计算新状态。”Store 接收函数却不定义规则,这样状态判断仍在 order 包里;内存与 SQLite 不必各写一次 pending 能否取消。

代码里的 default 是防御未知状态,不表示本例已经实现付款、确认或发货。添加一个新状态时,还需要同时定义新的转换和测试,不能只增加一个字符串常量。

为什么不先 Get,再调用 Save

考虑一个尚未实现的“确认订单”需求。若取消和确认都分成先读取、后保存,可能出现:

顺序取消请求 A确认请求 B
1读到 pending
2也读到 pending
3允许确认,写入 confirmed
4仍按旧的 pending 判断,写入 cancelled刚才的确认被覆盖

即使 Get 和 Save 各自加锁,两个调用之间仍有空隙。需要保护的不是某一行赋值,而是读取当前状态 → 判断是否允许变化 → 保存结果这个整体。因此 Store 接口提供 UpdateStatus,而不是让业务在外面拼接读写。

internal/memory/store.go节选 · L46
func (s *Store) UpdateStatus(ctx context.Context, id string, change func(string) (string, error)) (order.Order, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if err := ctx.Err(); err != nil {
		return order.Order{}, err
	}
	o, ok := s.orders[id]
	if !ok {
		return order.Order{}, order.ErrNotFound
	}
	status, err := change(o.Status)
	if err != nil {
		return order.Order{}, err
	}
	o.Status = status
	s.orders[id] = o
	return o, nil
}

在内存实现中,同一把锁从读取前一直持有到写回后。其他使用同一 Store 的操作不能插进这段过程。如果 change 返回错误,函数先退出,不会执行写回。

回调在保护范围内执行,因此必须短小,不做网络请求,也不再次调用 Store。否则可能长时间阻塞其他操作,甚至因重新获取同一把锁而死锁。

SQLite 用事务包住同一段工作

internal/sqlite/store.go节选 · L52
func (s *Store) UpdateStatus(ctx context.Context, id string, change func(string) (string, error)) (order.Order, error) {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return order.Order{}, err
	}
	defer tx.Rollback()
	current, err := scan(tx.QueryRowContext(ctx,
		`SELECT id, sku, quantity, status, created_at FROM orders WHERE id = ?`, id))
	if err != nil {
		return order.Order{}, err
	}
	status, err := change(current.Status)
	if err != nil {
		return order.Order{}, err
	}
	// The application currently changes only status. Other fields are immutable.
	if _, err = tx.ExecContext(ctx, `UPDATE orders SET status = ? WHERE id = ?`, status, id); err != nil {
		return order.Order{}, err
	}
	if err := tx.Commit(); err != nil {
		return order.Order{}, err
	}
	current.Status = status
	return current, nil
}

BeginTx 开始事务,SELECT 读取当前行,回调判断,UPDATE 写入新状态,最后 Commit 提交。回调拒绝、SQL 失败或提交失败时都不返回成功订单。defer Rollback 负责提前退出的清理;事务已经提交成功时,它不会再把提交撤销。

本教学应用还限定同一 Store 只有一个数据库连接,因此同进程的数据库操作会排队。换数据库、增加连接或启用多个实例时,要重新验证事务隔离和冲突处理;不能看到这里有 SELECT 和 UPDATE,就认为任何引擎照搬都安全。

只更新 status 也很重要。SKU、数量和创建时间没有被客户端传回覆盖,所以取消前后其他字段保持不变,正好对应本章开始观察的结果。

用同一份测试约束两个存储实现

internal/storetest/contract.go 接收一个 Store,memory 与 sqlite 的测试都调用它。其中这段专门验证“回调拒绝时不保存”:

internal/storetest/contract.go节选 · L26
	t.Run("rollback", func(t *testing.T) {
		rejected := errors.New("rejected change")
		_, err := store.UpdateStatus(ctx, o.ID, func(string) (string, error) {
			return order.Cancelled, rejected
		})
		if !errors.Is(err, rejected) {
			t.Fatalf("error = %v", err)
		}
		got, err := store.Get(ctx, o.ID)
		if err != nil || got != o {
			t.Fatalf("failed update modified order: %+v, %v", got, err)
		}
	})

回调故意同时返回 cancelled 和 error。如果 Store 错误地忽略 error,或先写再报错,后面比较整笔订单的断言就会失败。这比只检查“方法返回了错误”更严格。

internal/storetest/contract.go节选 · L46
	t.Run("concurrent_cancel", func(t *testing.T) {
		var wg sync.WaitGroup
		for range 20 {
			wg.Go(func() {
				got, err := service.Cancel(ctx, o.ID)
				if err != nil || got.Status != order.Cancelled {
					t.Errorf("cancel = %+v, %v", got, err)
				}
			})
		}
		wg.Wait()
	})

这里启动 20 个 goroutine 同时取消,要求每次都成功并得到 cancelled。它验证当前“重复取消无害”的约定,不能证明未来所有状态转换都正确。只有取消这种操作时,最终值可能碰巧相同;新增确认等互斥操作,还需要能复现对应交错的测试。

分别验证两个实现,再检查数据竞争
go test ./internal/memory ./internal/sqlite -run '^TestStoreContract$' -v -count=1
go test -race ./... -count=1

第一条中 duplicate、rollback、cancelled_context、concurrent_cancel、not_found 都应通过。race 检查的是内存数据竞争,不是业务规则证明;不支持 -race 的平台需在受支持的开发环境或 CI 补跑。

让重复取消报错,看看哪项约定被破坏

临时把 Service.Cancel 中 case Cancelled: 分支的返回改成 return "", ErrConflict,不要改 pending 分支。

先看业务测试,再看 HTTP 契约
go test ./internal/order -run '^TestCancelTwice$' -v -count=1
go test ./internal/httpapi -run '^TestHTTPJourney$' -v -count=1
预期失败的原因
TestCancelTwice:第一次成功,第二次得到 ErrConflict
TestHTTPJourney:第二次取消变成 409,不再是约定的 200
两项都 FAIL

一个业务改动影响到了 HTTP,但 HTTP handler 本身不需要变化,因为它已经认识 ErrConflict。测试将两层约定连在了一起。练习后恢复 Cancelled 分支为 return Cancelled, nil,重跑两项都应 PASS。

这一章需要带走的判断

Service 定义允许怎样变化;Store 保证判断与写入处在同一个受保护的操作中;协议入口只翻译结果。少了任意一环,都不能仅凭一个 mutex 或一个事务声称业务并发正确。

项目教程

构建一个订单服务

取消订单与处理并发

本页目录