电商交易链路通常被拆成商品、购物车、订单三大块,但真正做起来会发现这三块之间的数据流转远比界面展示复杂。一个看似简单的下单动作,背后至少涉及商品信息的快照、购物车条目的冻结、库存的预占与回滚、订单状态的推进。本文从工程实现的角度出发,重点讨论商品列表如何在高并发下保持稳定、购物车数据如何设计才能支撑多端同步、订单状态机如何避免常见的乱序问题。

商品列表的分页与缓存设计
商品列表是用户进入电商App后最先接触的界面,它的响应速度直接影响转化率。如果每次打开列表都直接查询数据库并做多表关联,当单表数据量达到百万级别时,查询延迟会明显上升。常见的做法是引入缓存层,但缓存不能无脑使用。商品列表具有明显的读多写少特征,适合把热门的列表页数据缓存到Redis中,同时设置合理的过期时间。过期时间不宜过长,否则运营调整商品价格或上下架状态后,用户看到的数据会长时间不更新。
列表接口的分页方式也需要谨慎选择。深度分页场景下,使用传统的OFFSET方式会随着页码增大而性能下降,因为数据库需要跳过前面所有记录。如果业务允许,可以改用游标分页或基于排序字段的键集分页。以游标分页为例,前端拿到上一页最后一条数据的游标值,下一次请求时以该游标为起点向后取数。后端需要保证游标字段在排序上是唯一的,通常使用商品ID加创建时间的组合。下面给出一个简化的查询示例,假设使用MySQL,列表按创建时间倒序排列:
-- 首页请求
SELECT id, title, price, stock, cover_url, created_at
FROM product
WHERE status = 1
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 第二页及后续,使用上一页最后一条的 created_at 和 id 作为游标
SELECT id, title, price, stock, cover_url, created_at
FROM product
WHERE status = 1
AND (created_at < '2025-06-01 12:00:00'
OR (created_at = '2025-06-01 12:00:00' AND id < 8902))
ORDER BY created_at DESC, id DESC
LIMIT 20;
这种写法虽然比OFFSET稍微复杂一点,但在数据量大的情况下性能优势非常明显。需要注意的是,如果列表支持筛选条件,游标条件必须和筛选条件同时生效,否则会出现数据错位。另一个细节是库存和价格在列表中是否需要实时准确。列表页为了保证响应速度,通常允许库存和价格有秒级或分钟级的延迟,真正精确的数据在进入商品详情页或下单校验阶段再二次确认。
购物车数据结构的选型与并发控制
购物车的数据结构直接决定了后续功能的扩展难度。简单做法是将购物车存成前端本地数据,用户未登录时也能加购,但这会导致换设备后购物车丢失,也无法做跨端推荐。更成熟的方案是服务端存储购物车,前端只做展示和操作入口。服务端购物车可以存在关系型数据库的购物车表中,也可以存在Redis的哈希结构中。数据库方案便于做复杂查询和数据分析,Redis方案读写性能更好,但需要额外的持久化策略防止数据丢失。
如果使用Redis存储购物车,一个用户对应一个哈希,哈希的键为用户ID,字段为商品SKU的ID,值为该SKU的数量。这种结构天然支持按用户维度快速读写和更新。但要注意哈希字段数量如果过大,比如一个用户加了上千种商品,Redis的大键问题就会浮现。工程中通常会限制购物车最大商品种类数,比如100种,超过后提示用户先清理或结算。此外,购物车中还需要保存加购时的商品快照信息,比如当时的标题和图片,避免商品信息变更后购物车展示出现不一致。这些快照信息可以放在哈希的值中,用JSON字符串存储。
未登录状态的购物车合并是另一个常见的难点。用户可能先以游客身份加购,登录后系统需要把游客购物车合并到登录购物车中。这里涉及合并策略的取舍:是同SKU数量相加,还是以登录购物车为准,或者以游客购物车为准。比较稳妥的做法是同SKU数量相加并取库存上限,但需要后端在合并时做一次库存校验,避免合并后的数量超过可售库存。合并完成后,游客购物车数据需要及时清理,防止后续重复合并。
购物车中的数量变更操作存在并发问题。用户可能在多个设备上同时操作同一个购物车条目,如果后端不做并发控制,就会出现最后写入覆盖的问题。以更新数量为例,前端直接提交目标数量,后端用带版本号或条件更新的方式写入。Redis的哈希结构可以使用Lua脚本来保证比较和更新的原子性。下面给出一个Lua脚本的简化版本,用于校验数量不超限并更新:
-- KEYS[1]: 购物车哈希键,如 cart:user:10086
-- ARGV[1]: SKU ID
-- ARGV[2]: 目标数量
-- ARGV[3]: 最大允许数量
local current = tonumber(redis.call('HGET', KEYS[1], ARGV[1]) or '0')
local target = tonumber(ARGV[2])
local maxQty = tonumber(ARGV[3])
if target <= 0 then
redis.call('HDEL', KEYS[1], ARGV[1])
return 0
end
if target > maxQty then
return -1
end
redis.call('HSET', KEYS[1], ARGV[1], target)
return target
这个脚本在Redis服务端执行,不需要额外的分布式锁就能避免读改写之间的竞争。对于数据库方案,则可以使用乐观锁或条件更新语句实现类似效果,比如在UPDATE语句中带上当前版本号,影响行数为0时说明有并发修改,需要重新读取后再尝试。
下单流程中的库存校验与订单状态机
下单是交易链路中最敏感的一环。库存扣减放在哪个时机执行,会直接影响系统的复杂度和用户体验。常见策略有两种:下单即减库存和支付成功后减库存。下单即减库存对用户更友好,能有效防止超卖,但如果用户下单后不支付,库存会被占用,需要设置订单超时自动取消并回补库存。支付后减库存可以减少无效占用,但在高并发下更容易出现超卖,需要依赖支付回调的幂等控制和库存服务的强一致校验。
无论选择哪种策略,库存扣减操作必须保证原子性。在数据库层面,可以使用带库存条件的UPDATE语句来扣减,影响行数为0说明库存不足。例如使用MySQL的InnoDB引擎,执行以下语句:
UPDATE sku_stock SET available_stock = available_stock - 1 WHERE sku_id = 30021 AND available_stock > 0;
这条语句在不加显式事务的情况下也能依靠行锁保证安全,但如果后续还有其他写操作,建议放入事务中统一提交。在Redis方案中,可以使用DECR配合Lua脚本判断扣减后的值不能小于0。需要注意库存数据与订单数据属于不同的存储,强一致需要引入分布式事务或消息队列的最终一致方案。很多中小项目选择以数据库为主,订单表和库存表在同一数据库中,用本地事务包裹,实现简单且足够可靠。
订单状态机的设计需要在扩展性和可维护性之间平衡。最简单的订单状态流转是待支付、已支付、已发货、已完成、已取消这条主线,但实际业务中可能还有部分退款、已关闭、售后中这样的分支状态。状态机的核心约束是合法状态之间的流转关系,非法流转必须在代码层面拦截。可以用一个状态流转映射表来管理,把允许的流转目标用枚举或配置定义清楚。下面是一个简化的订单状态定义和流转逻辑示意:
type OrderStatus int
const (
StatusPendingPayment OrderStatus = 10 // 待支付
StatusPaid OrderStatus = 20 // 已支付
StatusShipped OrderStatus = 30 // 已发货
StatusCompleted OrderStatus = 40 // 已完成
StatusCancelled OrderStatus = 50 // 已取消
)
var allowedTransitions = map[OrderStatus][]OrderStatus{
StatusPendingPayment: {StatusPaid, StatusCancelled},
StatusPaid: {StatusShipped, StatusCancelled},
StatusShipped: {StatusCompleted},
StatusCompleted: {},
StatusCancelled: {},
}
func canTransition(from, to OrderStatus) bool {
for _, target := range allowedTransitions[from] {
if target == to {
return true
}
}
return false
}
状态机的判断必须放在事务或原子操作中完成更新,避免两个并发请求同时把订单从待支付推进到不同状态。更新订单状态时,建议使用条件更新,比如在UPDATE语句的WHERE条件中带上当前状态,影响行数为0则拒绝本次流转。这样可以避免引入分布式锁的复杂度,也符合数据库的并发特性。
订单创建时还需要把商品信息做快照存储。用户下单后,商品标题、价格、图片都可能发生变化,如果订单表只存商品ID,后续查看订单时就会读取到变更后的商品数据,造成信息不一致。正确的做法是在订单条目表中冗余保存下单时的商品名称、单价、规格描述和封面图地址。快照数据虽然增加了存储成本,但对用户体验和售后服务至关重要。
最后需要关注的是下单接口的幂等性。用户网络抖动时可能重复提交订单,前端按钮置灰只能缓解,不能解决根本问题。后端应该在订单创建请求中要求客户端携带唯一的请求号,比如由前端生成的UUID。后端以该请求号作为幂等键,在创建订单前先去重查询,如果请求号已经存在则直接返回已有订单,不再重复创建。这个幂等键可以存储在数据库的唯一索引中,也可以放在Redis中做短期去重,结合订单号生成策略可以最大程度避免重复下单带来的资金和库存问题。