从零构建电商系统Keel:Go+PostgreSQL分布式事务与本地AI检索实战
1. 为什么我要自己写一套电商系统
电商系统这个赛道,开源方案一抓一大把,从轻量的商城模板到企业级中台,几乎每个技术栈都有对应的轮子。我之所以还要从零做 Keel,起因特别简单:去年帮一个朋友处理他们自营商城的库存超卖问题,翻遍了手头的开源方案,发现大部分在"订单创建"和"库存扣减"这两步之间,要么靠数据库行锁硬扛,要么干脆把一致性交给定时对账去兜底。平时流量小看不出问题,一到大促,超卖和少卖同时出现,财务对账对到怀疑人生。
这件事让我意识到,电商系统真正的难点从来不是商品列表怎么渲染、购物车怎么加,而是订单、库存、支付这几个动作跨服务之后,怎么保证数据不打架。而分布式事务恰恰是很多开源电商项目刻意回避的部分——因为难,因为容易写错,因为写错了很难测。
Keel 就是冲着这个痛点去的。技术选型上我定了三条线:后端用 Go,数据库用 PostgreSQL,分布式事务自己实现一套轻量的 TCC + 本地消息表 混合方案。另外还塞了一个我自己很在意的功能:AI 检索完全跑在本地,不依赖任何外部接口,商品语义搜索、以图搜图这类能力全部在用户自己的机器上完成。
这篇文章不是 Keel 的官方文档,而是我把这套系统从架构设计到落地踩坑的完整复盘。适合正在做电商后端、对分布式事务一知半解、或者想在自己项目里集成端侧 AI 检索的开发者。读完你至少能搞清楚三件事:订单和库存的一致性到底该怎么设计、Go + PostgreSQL 这套组合在电商场景下的真实表现、以及本地 AI 检索怎么落地才不拖垮主流程。
2. 技术选型背后的取舍逻辑
2.1 为什么是 Go,而不是 Java 或 Node
电商后端选语言,绕不开三个指标:并发吞吐、内存占用、开发迭代速度。Java 生态最成熟,但一个 Spring Cloud 微服务集群起步就是好几个 G 的内存,对小团队和自部署场景不友好。Node 写起来快,但 CPU 密集型的库存计算和事务协调容易成为瓶颈,而且回调地狱在复杂事务链路里会让人崩溃。
Go 的优势在于goroutine 的调度成本极低,一个订单创建请求要串起库存校验、优惠计算、事务协调好几个步骤,用 Go 写出来是同步阻塞的代码风格,但底层是异步调度,可读性和性能兼顾。实测下来,单机 4 核 8G 的配置,Keel 的订单接口在压测下能稳定跑到 3000+ QPS,内存常驻在 200MB 左右,这个数字对中小型电商完全够用。
还有一个很实际的原因:Go 的编译产物是单个二进制文件,部署的时候不用装运行时、不用配环境变量,扔到服务器上就能跑。对于我这种经常要帮别人私有化部署的人来说,这一点省了太多沟通成本。
2.2 PostgreSQL 在电商场景里被低估的能力
很多人一提电商数据库就想到 MySQL,但 Keel 我坚持用 PostgreSQL,原因有几个很硬核的点。
第一是 JSONB 类型。电商的商品属性千奇百怪,服装有尺码颜色、数码有内存版本、生鲜有产地保质期,用传统的关系表要么建一堆稀疏字段,要么搞 EAV 模型查询性能稀烂。PostgreSQL 的 JSONB 可以直接把动态属性存成一个字段,还能建 GIN 索引,查询 attributes @> '{"color": "red"}' 这种条件走索引飞快。
第二是 事务隔离级别的完整性。PostgreSQL 完整支持 SERIALIZABLE 隔离级别,而且是通过 SSI(Serializable Snapshot Isolation)实现的,不会像某些数据库那样靠锁表来实现。库存扣减这种场景,用 SELECT ... FOR UPDATE 配合合适的事务隔离,能避免大部分并发问题。
第三是 原生支持逻辑复制和 CDC。Keel 的本地消息表方案需要捕获数据变更,PostgreSQL 的逻辑复制槽可以直接把变更流推出来,不用额外装 Canal 之类的中间件。
提示:如果你打算用 PostgreSQL 跑电商,建议直接上 16 或 17 版本。16 开始逻辑复制的性能有明显提升,17 的 vacuum 策略对高频写入的订单表更友好。Windows 环境下可以用便携版快速验证,Linux 服务器上建议用官方源安装而不是系统自带的旧版本。
2.3 分布式事务:为什么不用现成的框架
Seata、DTM 这些分布式事务框架都很成熟,但 Keel 选择自己实现,核心原因是电商的事务模型其实很固定。订单创建这条链路,无非就是"创建订单 → 扣库存 → 扣优惠券 → 记流水",每一步都是可补偿的,用 TCC 的 Try-Confirm-Cancel 三段式刚好贴合。
自己实现的好处是没有额外依赖。Seata 需要单独部署 TC 协调器,DTM 也要跑一个服务,对自部署场景来说都是负担。Keel 把事务协调逻辑直接做进了应用层,用 PostgreSQL 的本地事务保证协调日志的原子性,配合本地消息表做最终一致性的兜底。
代价是通用性差一些,只适合电商这类业务模型。但 Keel 本来就是电商系统,这个取舍我认为是划算的。
3. 订单与库存的分布式事务实现细节
3.1 TCC 三段式在库存扣减上的具体落地
先讲清楚 TCC 在 Keel 里到底怎么跑的。假设用户下单买 2 件商品,库存服务需要预留这 2 件。
Try 阶段:不是直接扣减库存,而是在库存表里插入一条"预留记录",字段包括订单号、商品 ID、预留数量、状态(reserved)。同时把可售库存字段减去预留数量。这一步是幂等的,同一个订单号重复 Try 只会成功一次。
Confirm 阶段:订单支付成功后,把预留记录状态改成 confirmed,同时把"已售库存"加上预留数量。这一步只是状态流转,不涉及库存数量的二次计算,所以很快。
Cancel 阶段:订单超时或支付失败,把预留记录状态改成 cancelled,可售库存加回预留数量。
这里有个关键设计:可售库存和已售库存分开存。很多系统只有一个库存字段,扣减的时候直接减,回滚的时候直接加,看起来简单,但一旦出现并发,加减的顺序和时机就会导致数据错乱。分成两个字段之后,Try 只动可售库存,Confirm 只动已售库存,Cancel 只回滚可售库存,每个阶段的职责单一,出问题容易定位。
3.2 本地消息表怎么兜住最终一致性
TCC 解决了强一致场景,但有些步骤没法做到实时确认,比如"下单成功后给用户发积分""通知仓储系统准备发货"。这些动作如果同步做,会拖慢主流程;如果异步做,又可能丢消息。Keel 用本地消息表来解决。
原理很朴素:在业务操作的同一个数据库事务里,往消息表插一条待发送记录。因为和业务操作在同一个事务,所以要么都成功,要么都失败,不会出现业务成功但消息丢失的情况。然后有一个后台协程不断扫描消息表,把待发送的消息投递出去,投递成功后更新消息状态。
后台投递协程用 SELECT ... FOR UPDATE SKIP LOCKED 来取待发送消息,这个语法是 PostgreSQL 的杀手锏,多个投递实例可以并行工作而不会互相阻塞,天然支持水平扩展。
注意:本地消息表方案的关键是投递必须幂等。消费端要根据消息 ID 去重,否则网络抖动导致的重投会产生重复积分、重复通知。Keel 在消费端维护了一张去重表,消息 ID 作为唯一键,插入冲突就说明已经处理过。
3.3 超时订单的自动回滚怎么设计
用户下单后不付款是常态,库存不能一直被占着。Keel 用延迟消息 + 定时扫描双保险来处理超时订单。
延迟消息的思路是:订单创建成功后,往延迟队列投一条 30 分钟后触发的消息。时间到了检查订单状态,如果还是待支付,就触发 Cancel 流程。但延迟消息本身可能丢,所以再加一个定时任务,每分钟扫描一次超过 30 分钟仍未支付的订单,做兜底处理。
这里有个坑我踩过:定时扫描的时间窗口不能设得太短。一开始我设成每分钟扫描"创建时间在 30 到 31 分钟之间"的订单,结果因为时钟漂移和任务执行延迟,有些订单被漏掉了。后来改成扫描"创建时间早于 30 分钟前且状态为待支付"的所有订单,配合订单号去重,就稳了。
Cancel 流程本身也要幂等。因为延迟消息和定时任务可能同时触发同一个订单的取消,所以 Cancel 操作要先检查预留记录状态,只有 reserved 状态才执行回滚,confirmed 或 cancelled 直接返回。
4. 本地 AI 检索的工程化落地
4.1 为什么要把 AI 检索放在本地
电商的搜索体验直接决定转化率。传统的关键词匹配有个致命问题:用户搜"适合夏天穿的透气运动鞋",关键词匹配只能命中包含这些词的标题,但很多商品标题写的是"网面跑步鞋 轻便",语义上完全匹配,字面上一个词都对不上。
向量检索能解决这个问题,把商品描述和用户查询都转成向量,算余弦相似度。但主流做法是调用云端 embedding 接口,这带来三个问题:成本随调用量线性增长、有网络延迟、商品数据要传到第三方。
Keel 的方案是把 embedding 模型直接跑在本地。用的是量化后的小型文本嵌入模型,模型文件大概几十 MB,加载后常驻内存。商品上架时异步生成向量存进 PostgreSQL 的 pgvector 扩展,用户搜索时实时把查询转成向量做相似度检索。
4.2 pgvector 的索引选择和参数调优
pgvector 支持两种索引:IVFFlat 和 HNSW。IVFFlat 建索引快、占空间小,但召回率依赖聚类中心的数量,需要数据量足够大才能发挥效果。HNSW 建索引慢、占内存多,但查询性能和召回率都更好,而且支持增量插入。
Keel 选的是 HNSW,因为电商商品是持续上架的,IVFFlat 每次数据分布变化大了都要重建索引,运维成本太高。HNSW 的参数里,m 控制每个节点的连接数,ef_construction 控制建索引时的搜索范围,这两个值越大索引质量越高但建索引越慢。实测下来 m=16, ef_construction=64 是个不错的平衡点,百万级商品建索引大概十几分钟。
查询时的 ef_search 参数控制搜索范围,默认 40,调大能提高召回率但降低速度。Keel 默认设成 100,在千万级向量下查询延迟仍在 50ms 以内。
4.3 混合检索:向量 + 关键词 + 业务规则
纯向量检索有个问题:精确匹配场景会翻车。用户搜某个具体型号"XYZ-2000",向量检索可能返回一堆语义相近但型号不同的商品。所以 Keel 用的是混合检索策略。
第一路走向量检索,召回语义相关的商品;第二路走 PostgreSQL 的全文检索,用 tsvector 和 tsquery 做关键词匹配;第三路走业务规则,比如用户历史购买品类、商品销量权重。三路结果用 RRF(Reciprocal Rank Fusion)算法融合,取综合排名最高的。
RRF 的好处是不需要调权重,直接按排名倒数相加,对异构的检索结果很友好。实测下来混合检索的点击率比纯向量检索高了将近 20%,比纯关键词高了 40% 多。
提示:本地 embedding 模型的选择很关键。不要盲目追求大模型,电商场景下商品描述通常不长,384 维的小模型效果已经够用,推理速度快好几倍。模型量化到 INT8 之后,单条文本 embedding 在普通 CPU 上大概 10ms 左右,完全能撑住实时搜索。
5. 部署与运维中那些文档不会写的事
5.1 PostgreSQL 的安装版本和参数配置
Keel 对 PostgreSQL 的最低要求是 14,但强烈建议 16 以上。安装方式上,Linux 服务器我推荐用官方 APT/YUM 源,不要用系统自带的版本,因为自带的往往缺 pgvector 等扩展。Windows 上做开发验证可以用便携版,解压即用,但生产环境还是老老实实装服务。
参数配置里,电商场景最需要调的是这几个:
| 参数 | 默认值 | 建议值 | 说明 |
|---|---|---|---|
| shared_buffers | 128MB | 内存的 25% | 缓存热数据,订单表查询频繁 |
| work_mem | 4MB | 16-64MB | 排序和哈希操作的内存,影响复杂查询 |
| max_connections | 100 | 200-500 | 配合连接池使用,不要盲目调大 |
| wal_buffers | -1 | 16MB | 写入频繁时减少 WAL 刷盘次数 |
| effective_cache_size | 4GB | 内存的 50-75% | 告诉优化器有多少缓存可用 |
max_connections 这个参数特别容易踩坑。很多人一看连接不够就往上调,结果每个连接都占内存,调太大反而拖垮数据库。正确做法是用 PgBouncer 做连接池,应用侧只维持少量长连接。
5.2 Go 服务的编译和交叉编译技巧
Go 的交叉编译是部署利器。在 Mac 上开发,直接编译出 Linux 的二进制:
CGO_ENABLED=0 很关键,关掉 CGO 之后编译产物是纯静态的,不依赖系统的 glibc 版本,扔到任何 Linux 上都能跑。但要注意,如果用了依赖 CGO 的库(比如某些 SQLite 驱动),这个开关就失效了。Keel 全程用纯 Go 的 PostgreSQL 驱动,所以没这个问题。
编译时加 -ldflags "-s -w" 可以去掉调试信息,二进制体积能小 30% 左右。生产环境还可以用 -trimpath 去掉本地路径信息,更安全。
5.3 本地 AI 模型的加载和内存管理
本地 embedding 模型加载后是常驻内存的,这部分内存要提前规划。384 维的小模型量化后大概占 100-200MB,如果同时加载多个模型(比如文本 + 图像),内存占用会叠加。
Keel 的做法是懒加载 + 空闲释放。服务启动时不加载模型,第一次搜索请求进来才加载,加载后如果 30 分钟没有请求就释放掉。这样既能保证首次搜索的延迟可接受,又不会让闲置的服务白占内存。
模型推理本身是 CPU 密集型的,如果搜索 QPS 高,会和其他业务抢 CPU。Keel 把推理放在独立的 goroutine 池里,限制并发数,避免把 CPU 打满影响订单接口。这个池的大小设成 CPU 核数就行,设太大反而因为上下文切换降低吞吐。
6. 几个真实踩过的坑和排查过程
6.1 库存扣减出现负数:一次并发问题的完整排查
上线压测的时候发现库存偶尔会变成负数,但代码里明明有 available_stock >= quantity 的判断。排查过程记录一下,因为这类问题很典型。
第一步先看日志,发现负库存都出现在高并发场景,而且集中在同一个商品上。第二步看 SQL,扣减语句是 UPDATE inventory SET available_stock = available_stock - $1 WHERE product_id = $2 AND available_stock >= $1。这条语句本身是原子的,理论上不会出问题。
第三步查事务隔离级别,发现用的是默认的 READ COMMITTED。问题就出在这里:READ COMMITTED 下,两个事务可以同时读到相同的库存值,然后都通过判断,都执行扣减。虽然 UPDATE 本身会加行锁,但判断和扣减如果在不同语句里,中间就有窗口。
修复方案有两个:一是把判断和扣减合并成一条 UPDATE 语句(Keel 本来就是这么写的,但有个分支漏了),二是把隔离级别提到 REPEATABLE READ。最终选了方案一,因为性能更好,而且逻辑更清晰。
注意:库存扣减一定要用
UPDATE ... WHERE stock >= quantity这种带条件的原子操作,不要先 SELECT 再 UPDATE。前者是数据库层面的原子性保证,后者依赖应用层的锁,容易出错。
6.2 本地消息表积压:投递协程的并发陷阱
有次测试环境消息表积压了几万条,投递协程看起来在跑但就是消费不动。查了半天发现是 SELECT ... FOR UPDATE SKIP LOCKED 的用法有问题。
原来的写法是先 SELECT 一批消息,然后在循环里逐条处理,处理完再 UPDATE 状态。问题是 SELECT 的时候加了 FOR UPDATE,锁一直持有到事务结束,而事务里又包含了耗时的投递操作,导致锁竞争严重,多个投递协程互相等待。
改成先 SELECT 出消息 ID,立即提交事务释放锁,然后异步投递,投递完再单独 UPDATE 状态。这样锁的持有时间从秒级降到毫秒级,吞吐量直接上来了。代价是投递失败时消息状态可能不一致,但配合重试机制和幂等消费,这个问题可以接受。
6.3 向量检索召回率低:索引参数和查询语句的双重问题
语义搜索刚上线时,用户反馈搜出来的东西不相关。排查发现两个问题叠加。
第一个是 HNSW 索引的 ef_search 设得太小,默认 40 在数据量大了之后召回率明显下降。调到 100 之后召回率上来了,但查询延迟也涨了。最后做了个折中:普通搜索用 100,对延迟敏感的场景用 60。
第二个是查询语句的问题。原来用的是 ORDER BY embedding <=> $1 LIMIT 20,这个写法会先算所有向量的距离再排序,数据量大时很慢。改成先用 WHERE embedding <=> $1 < 0.5 过滤掉明显不相关的,再排序取 Top 20,速度快了好几倍。
这两个问题单独看都不难,但叠在一起就让人摸不着头脑。排查的时候一定要把变量拆开单独验证,不要一次改多个地方,否则不知道是哪个改动起了作用。
7. 关于 Keel 后续可以怎么玩
Keel 现在的状态是核心链路跑通了,订单、库存、支付、搜索都能用,但离一个完整的电商系统还有距离。我自己后续想做的几个方向,也分享给有兴趣的人参考。
第一个是多租户支持。现在一套 Keel 服务对应一个商城,如果要做 SaaS 化的多商户平台,需要在数据层做租户隔离。PostgreSQL 的 Row Level Security 是个不错的切入点,可以在数据库层面强制租户隔离,应用层不用改太多代码。
第二个是AI 检索的多模态扩展。现在只支持文本搜索,加上图像 embedding 之后就能做以图搜图。商品主图直接生成向量,用户上传一张图就能找到相似商品。这个功能对服装、家居类电商特别有用。
第三个是事务协调器的独立部署。现在事务协调逻辑在应用层,好处是简单,坏处是应用重启时正在协调的事务会中断。后续可以把协调器抽出来做成独立服务,用 Raft 做高可用,这样事务的可靠性会更高。
如果你也在做电商后端,或者对 Go + PostgreSQL 这套组合感兴趣,Keel 的代码结构还算清晰,核心模块都有注释。分布式事务那部分建议重点看 internal/tcc 和 internal/message 两个包,本地 AI 检索看 internal/search。踩过的坑我都写进注释和测试用例里了,希望能帮你少走点弯路。
相关推荐
基于Python+Open CV的手势识别算法设计
伴随着人工智能时代的到来,人机交互的领域也逐渐成为研究的一大方向;其中,手势识别是人机交互领域的一项关键技术,自此手势识别也迎来了一波高潮,近几年无论是在消费领域、电子领域、数码领域、家电领域甚至汽车领域上,都能或多或少的见到手势识别的身影。 手势识别在设计智能高效的人机界面方面具有至关重要的作用, 目前手势识别已应用到手语识别、智能监控、到虚拟现实等各个领域,手势识别的原理都是利用各种传感器(例如红外、摄像头等)对手部的形态进行捕捉并进行建模,形成一个模型信息的序列帧,而后将这些信息序列转换为机器能够识别的相对应指令(例如打开、切换菜单、移动等)来完成控制。 采用Python的集成开发环境Pycharm进行本次课程设计,在Pycharm中进行需要库(模块)的下载,调取电脑摄像头,按帧读取摄像头采集到的头像,形态学处理,图像旋转(由于摄像头采集到的图像是镜像,需要用cv2.flip将摄像头采集到的图像进行处理),选取图片中固定位置作为手势输入,用红线画出手势识别框,基于hsv的肤色检测,进行高斯滤波,找出轮廓,求出图像中手势的凹凸点,手指间角度求取。
ROS-CV-ImageConverter:将ROS图像转换为OpenCV(cv :: Mat)类型的图像
ROS-CV图像转换器 依存关系 cv_bridge(ROS软件包)
计算机视觉图像处理-CV2与图像插值算法
用最近邻插值与双线性插值原理完成图像缩放 最近邻插值,是指将目标图像中的点,对应到源图像中后,找到最相邻的整数点,作为插值后的输。缺点:使用最近邻插值算法处理图像放大时,在图象中可能出现明显的块状效应 双线性插值,在两个方向上做三次线性插值。使用双线性插值算法处理图像放大时,在图象中可能出现马赛克 代码 import cv2 img = cv2.imread(’./xiabang.jpg’, cv2.IMREAD_UNCHANGED) #缩放比例 scale_percent = 0.3 width = int(img.shape[1] * scale_percent) height = int
基于水平集CV模型的图像分割(Matlab代码)
基于水平集CV模型的图像分割(Matlab代码),全套代码
导向滤波(guide_filter)的python实现
导向滤波(guide_filter)的python实现,可以结合暗通道;理论实现比较好的图像去噪效果。

