接手这套手机商城管理系统时,代码全堆在同一个 Flask 工程里:用户、商品、订单、购物车、后台管理,几乎所有模块的模型和路由都挤在几个大文件里。最让人头疼的不是功能不好写,而是每次改订单逻辑,回归范围波及整个应用,任何一个小服务的内存泄漏都能让全站接口响应从 50ms 掉到 3 秒。项目组反复评估后决定用微服务架构重构,技术栈继续保留 Python 和 Flask,把系统拆成网关、用户、商品、购物车、订单、管理后台六个独立服务,最后用 Docker Compose 跑通了整套部署。这篇文章把这次重构的选型过程、服务拆分方案、分布式数据一致性处理,以及可以直接抄作业的代码骨架都整理出来。如果你在准备电商类项目,或者想把一个单体 Flask 工程改造成微服务,这篇应该能帮你省不少弯路。
1. 选型困境:为什么不继续用单体,而是选择 Flask 微服务
1.1 单体 Flask 商城的问题出在哪
先还原一下重构前的真实状态。项目里有一个app.py作为入口,下面挂了十几个蓝图,models.py里定义了用户、商品、购物车、订单、支付记录等二十多张表,业务函数之间互相 import。表面上功能都能跑,但维护到后期会非常痛苦。
第一个痛点是耦合。比如用户下单时,代码同时操作了用户表、订单表、商品库存、购物车表,看起来是"一次请求干完所有事",但这些操作散落在不同模块,改订单状态时要小心翼翼,因为可能有别的地方也在读写同一条订单。第二个痛点是发布风险。单体应用只有一个进程,任何模块的代码变更都会导致整个应用重启,哪怕只是改了一个文案。第三个痛点是扩容浪费。做活动时商品接口请求量特别高,但单体架构下只能整站增加进程,用户模块、订单模块全部一起扩,资源利用率很低。
这些问题单独看都能忍,叠加在一起就会很影响迭代效率。重构不是赶时髦,而是开发节奏真的被卡住了。
1.2 Django、FastAPI、Flask 三选一,我为什么还是回了 Flask
准备拆分时,团队内部对比过三个框架,当时列了一张表:
| 框架 | 优势 | 劣势 | 作为微服务框架的评价 |
|---|---|---|---|
| Django | 全家桶,自带 ORM、Admin、鉴权,开发快 | 框架重,服务内即使只用到一个功能也背着完整 Django 运行时,启动和内存开销大 | 适合单体快速交付,拆成微服务后每个服务都太重 |
| FastAPI | 异步原生,性能好,自动生成 OpenAPI 文档 | 生态相对年轻,ORM、缓存、后台任务等周边方案还在磨合期 | 新项目可以优先考虑,老团队迁移有学习成本 |
| Flask | 轻量、灵活,核心足够小,扩展按需加载 | 异步支持弱,社区约定少,需要自己定规范 | 对"小核心 + 按服务组装扩展"的微服务思路非常契合 |
最后选 Flask,原因很直接:项目组对 Flask 最熟,迁移成本最低。微服务架构本身已经增加了不少复杂度,框架层面就没必要再引入新变量。Flask 的哲学是"核心只负责路由和请求响应,其他能力通过扩展接入",拆出来的每个服务可以根据自己的职责只带需要的扩展,比如用户服务带 Flask-JWT-Extended,商品服务带 Flask-SQLAlchemy,订单服务再单独加一个消息表模型,这种松耦合的方式和微服务天然合拍。
1.3 这个项目到底要交付什么
标题里"管理系统"这个词容易被忽略,但它是需求的真正重心。这套系统不是只给普通用户下单用的,还包括一个完整的管理后台。普通用户端要覆盖注册登录、浏览商品分类和详情、加购物车、下单、模拟支付;管理后台要覆盖商品上下架、库存调整、订单状态处理、用户管理、销售数据统计。拆分时不管是用户服务还是商品服务,每个服务都要同时提供"面向用户端"和"面向管理端"的两组接口,只是入口路由和管理员权限校验方式不同。
2. 商城业务拆解:六个服务按什么边界来切
2.1 判断服务边界的核心标准
拆微服务最容易犯的错是"按页面拆":前端有购物车页面就拆购物车服务,有关卡页面就拆关卡服务。实际上页面是会变的,按页面拆以后每次页面调整都要联动多个服务,反而更痛苦。
我判断边界主要看三条标准。第一是数据归属:每个服务必须拥有独立的一组数据表,其他服务不能直接跨库查询。第二是业务闭环:一个完整的业务动作应该尽量在一个服务内完成,比如购物车服务管自己的增删改,订单服务管下单和状态流转。第三是变化频率:把变化频繁的业务单独拎出来,避免它拖累稳定的模块。
2.2 六个服务全景图
基于这三条标准,最终切出了六个服务,加上一个 Nginx 做入口:
| 服务名 | 职责 | 核心数据表 | 主要接口 |
|---|---|---|---|
| api-gateway | 统一入口、JWT 鉴权、路由转发、限流 | 无 | /api/user/, /api/product/等转发 |
| user-service | 注册登录、用户信息、收货地址 | user, address | 注册、登录、获取用户信息、地址增删改 |
| product-service | 商品分类、SPU/SKU、库存、上下架 | category, product, sku, stock | 商品列表、详情、库存扣减、后台商品管理 |
| cart-service | 购物车增删改、选中状态 | cart_item | 购物车查询、加入、修改数量、删除 |
| order-service | 下单、订单状态机、支付回调记录 | order, order_item, message_record | 创建订单、订单详情、取消订单、支付回调 |
| admin-service | 管理后台聚合、销售统计、用户管理 | admin_user, daily_sales_report | 管理员登录、订单管理列表、销售报表 |
这里要注意网关层我保留了 Nginx 做最外层的反向代理和静态资源服务,api-gateway 这个 Flask 服务负责业务层的统一鉴权和路由。没有把 Nginx 的职责全部塞进 Flask,是考虑到 SSL 终止、Gzip、静态文件这些能力用 Nginx 更成熟,没必要自己写一遍。
2.3 数据隔离:每个服务独享数据库
数据隔离这个决策要下得坚决。六个服务对应六个独立数据库:user_db、product_db、cart_db、order_db、admin_db,物理上分开,应用层完全不跨库 join。这样做确实会带来一些麻烦,比如查询"某个用户的订单列表"需要先调用用户服务拿用户 ID,再调用订单服务查列表,但好处是每个服务的表结构可以独立演进,不必协调其他团队一起改数据库。
实际开发里还会用到数据冗余来降低跨服务查询频率。一个典型例子是订单明细表里冗余了商品名称、商品图片、下单单价,这样用户查看历史订单时,订单服务自己就能返回完整数据,即使商品后来改名或下架,历史订单也不会被影响。库存数量则在商品服务和订单服务之间通过扣减接口同步,不直接查询对方的表。
2.4 拆分的代价:先把丑话说在前头
选择微服务不是免费的,拆分后立刻要面对三件事:原来的数据库 join 查询变成多次服务调用;一个请求可能跨三四个服务,日志要跟着 request_id 联动追踪;部署从"启动一个进程"变成"编排六个服务"。如果项目整体代码量只有几千行、团队只有一两个人,我不建议上微服务,单体反而更高效。当时之所以坚持拆,是因为系统已经出现明显的模块耦合,而且后续有计划把商品模块单独做活动系统,必须提前打好边界。
3. 一次下单怎么穿过四个服务:通信模型与数据一致性
3.1 从加入购物车到支付成功的完整链路
先看用户侧的真实流程,我用步骤来描述这条链路:
- 用户登录,浏览器带上 access_token 访问
POST /api/order/create。 - Nginx 把请求转发给 api-gateway,网关校验 token 合法后,按路径前缀
/api/order把请求转发到 order-service。 - order-service 收到请求后先做参数校验,生成订单号,在本地数据库事务里同时写入订单记录和一条"待发送"的本地消息。
- 本地消息表里有一条库存扣减消息,后台线程把它投递到 Redis Stream。
- product-service 消费 Redis Stream 里的库存消息,用 Lua 脚本原子扣减 Redis 库存,扣减成功后回写 MySQL,再向 order-service 发一个"扣减成功"的内部回调。
- order-service 收到成功回调后把订单状态从未支付变为已支付,然后触发模拟支付流程。
这里最关键的一点是:订单创建和库存扣减不是同一个数据库事务,不能靠commit/rollback保证一致性,必须用消息机制做最终一致。
3.2 网关统一鉴权和内部服务之间的信任
网关是用户请求的必经之地,JWT 校验在网关做一次就够了。用户登录成功后,user-service 签发一个 access_token,token 里带上用户 ID 和角色。后续请求到 api-gateway 时,网关统一解析 token,把X-User-Id和X-User-Role这两个请求头注入到转发的请求里。各业务服务基本不用再解析 JWT,直接信任请求头就行。
但这带来一个问题:内部服务之间的调用是否完全信任网关之外的请求?绝对不能。我为内部服务之间设计了internal_token,网关启动时从环境变量读取一个随机密钥,转发请求时会附加一个签名的内部头,各服务校验这个内部头才允许执行写操作。这样即使某个服务被非法访问,攻击者也无法伪造内部服务身份直接修改库存或订单。
核心的 JWT 校验逻辑我封装成了一个装饰器:
def login_required(fn): @wraps(fn) def wrapper(*args, **kwargs): auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): return jsonify({"code": 401, "msg": "未登录"}), 401 token = auth_header[7:] try: payload = jwt.decode(token, current_app.config["JWT_SECRET"], algorithms=["HS256"]) except Exception: return jsonify({"code": 401, "msg": "token无效"}), 401 g.user_id = payload["uid"] g.user_role = payload.get("role", "user") return fn(*args, **kwargs) return wrapper3.3 跨服务调用的幂等与超时处理
跨服务调用最怕两件事:重复扣款、调用方超时重试导致数据被多次修改。我在所有写接口里强制要求请求头带上request_id,服务端用 Redis 记录已经处理过的 request_id,命中就直接返回上一次的响应结果。
幂等装饰器的大致逻辑:
def idempotent(fn): @wraps(fn) def wrapper(*args, **kwargs): request_id = request.headers.get("X-Request-Id") if not request_id: return jsonify({"code": 400, "msg": "缺少request_id"}), 400 cached = redis_client.get(f"idem:{request_id}") if cached: return jsonify(json.loads(cached)) resp = fn(*args, **kwargs) if resp[1] == 200: redis_client.setex(f"idem:{request_id}", 3600, json.dumps(resp[0])) return resp return wrapper超时方面,内部调用统一设置了连接超时 2 秒、读取超时 5 秒。重试策略采用"指数退避":第一次失败后等 1 秒重试,然后 2 秒、4 秒,最多重试 5 次。因为接口本身幂等,重试是安全的。
3.4 本地消息表:用最终一致性替代分布式事务
一开始团队考虑过两阶段提交(2PC),但评审后放弃了。2PC 需要一个全局协调者,协调者本身就是单点;而且每个事务在整个流程中要多次阻塞等待,商城高并发场景根本扛不住。最终选了本地消息表这套最终一致性方案。
本地消息表的核心思路是:把"跨服务的分布式事务"变成"本地事务 + 消息投递"。下单时在 order-service 的数据库里,开启一个事务同时插入订单记录和消息记录,这两个操作要么都成功,要么都失败,由本地数据库保证。事务提交后,后台异步任务去扫描状态为"待发送"的消息,把它投到 Redis Stream,product-service 消费后扣库存,成功后通过回调接口把消息标记为"已成功"。
消息表结构:
CREATE TABLE message_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_id VARCHAR(64) NOT NULL UNIQUE, body JSON NOT NULL, target_service VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送 2完成 3失败', retry_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE = InnoDB;后台投递任务的伪代码:
def scan_and_deliver(): while True: rows = db.session.execute( text("SELECT * FROM message_record WHERE status = 0 AND next_retry_time <= NOW() LIMIT 100") ).fetchall() for row in rows: success = stream_client.xadd("stock_deduct_queue", { "msg_id": row.msg_id, "order_id": row.body["order_id"], "sku_id": row.body["sku_id"], "quantity": row.body["quantity"], }) if success: db.session.execute(text("UPDATE message_record SET status=1 WHERE msg_id=:mid"), {"mid": row.msg_id}) db.session.commit() time.sleep(1)product-service 消费消息后,如果扣库存成功会调用 order-service 的回调接口,把消息状态改成 2;如果失败,product-service 可以把这个消息放回重试队列。重试超过 10 次仍失败,消息状态改成 3,进入人工补偿列表。这套方案不能用严格的一致性证明,但实际业务里几乎没有出现过不一致,因为每个环节都有重试和补偿兜底。
4. 核心服务落地:Flask 骨架与几个关键设计
4.1 统一的应用工厂,六个服务复用同一套骨架
每个服务虽然在职责上不同,但基础结构完全一样:读取配置、初始化数据库、注册蓝图、统一错误处理。所以我写了一个共享的create_app工厂,各服务只需要在入口文件里调用它并传入自己的配置对象即可。
def create_app(config_class): app = Flask(__name__) app.config.from_object(config_class) db.init_app(app) redis_client.init_app(app) jwt.init_app(app) # 统一异常处理 @app.errorhandler(404) def handle_404(e): return jsonify({"code": 404, "msg": "接口不存在"}), 404 @app.errorhandler(500) def handle_500(e): current_app.logger.exception("internal error") return jsonify({"code": 500, "msg": "服务内部错误"}), 500 return app以用户服务为例,入口文件只有几行:
app = create_app(UserServiceConfig) app.register_blueprint(user_bp, url_prefix="/api/user")采用应用工厂的好处是:测试时可以传入不同配置创建多份应用实例,部署时也方便把同一份代码跑多个副本。
4.2 用户服务:登录签发 JWT 与管理员角色
用户服务的重点不在增删改查,而在登录态和权限模型。登录接口验证完密码后,签发 access_token 和 refresh_token。管理员和普通用户区分方式是在 token 里加一个role字段。
@user_bp.post("/login") def login(): data = request.get_json() user = User.query.filter_by(username=data.get("username")).first() if not user or not check_password_hash(user.password_hash, data.get("password")): return jsonify({"code": 401, "msg": "用户名或密码错误"}), 401 if user.is_banned: return jsonify({"code": 403, "msg": "账号已被禁用"}), 403 access_token = jwt.encode( {"uid": user.id, "role": user.role}, current_app.config["JWT_SECRET"], algorithm="HS256" ) return jsonify({"code": 0, "data": { "access_token": access_token, "expires_in": 7200, "role": user.role, }})这里有一个细节很容易被忽略:用户被管理员禁用之后,token 本身还是有效的,所以要在网关层维护一个 Redis 黑名单,禁用用户时把用户 ID 提前加入黑名单,网关校验 token 时同步检查。
4.3 商品服务:Redis Lua 脚本防止库存超卖
库存超卖是商城项目里最高频的问题。一开始我用数据库方式扣库存:
UPDATE sku SET stock = stock - 1 WHERE sku_id = ? AND stock > 0这个 SQL 在高并发下确实能保证不超卖,但会有大量行锁等待,压测时数据库 CPU 直接顶满。后来把库存热点放到了 Redis,用 Lua 脚本保证原子性:
-- KEYS[1] 当前库存 key,ARGV[1] 扣减数量 local stock = tonumber(redis.call('get', KEYS[1]) or '0') if stock >= tonumber(ARGV[1]) then redis.call('decrby', KEYS[1], ARGV[1]) return 1 end return 0为什么用 Lua 脚本而不用简单的"先读再写"?因为 Redis 单线程执行脚本,脚本执行期间不会插入其他命令,从读取库存到扣减是原子操作。在 Java 里很多人用分布式锁,但 Redis 里一个原子脚本比锁更轻量、性能更高。
库存扣减成功后,商品服务并不立即把库存同步回 MySQL,而是先记录一条待同步的任务,异步批量更新。Redis 里的库存数是"可销售库存",MySQL 里的库存数是底账,两者在活动高峰期短暂不一致是可以接受的,但最终要一致,所以异步同步任务要做校验和补偿。
4.4 订单服务:状态机设计
订单状态是商城系统的核心柱石。我见过很多项目把订单状态写成 if 嵌套,最后没人说得清楚某个状态能不能跳到另一个状态。这次我直接用一张状态机映射表来定义合法流转:
ORDER_STATUS_FLOW = { "待支付": ["已取消", "已支付"], "已支付": ["已发货", "已退款"], "已发货": ["已完成"], "已取消": [], "已完成": ["已退款"], "已退款": [], } def update_order_status(order, new_status): if new_status not in ORDER_STATUS_FLOW[order.status]: raise BusinessError(f"订单状态不允许从{order.status}变为{new_status}") old_status = order.status order.status = new_status db.session.add(OrderStatusLog( order_id=order.id, old_status=old_status, new_status=new_status, operator=g.user_id, )) db.session.commit()改动状态时必须同时写入一条状态变更日志,这个习惯帮我排查过好几次线上问题。比如用户投诉"我没退款但订单变已退款了",一查状态日志就发现是运营同学在哪一步手动操作了。
4.5 管理后台独立成服务的原因
admin-service 不是简单套壳,而是把"管理端"和"用户端"严格分开。管理员登录接口、用户列表查询接口、商品上下架接口、订单管理接口、销售统计接口都放在这个服务里,并且所有接口都要求 token 里的角色是admin。
销售统计没有用实时聚合,而是每天凌晨生成一张daily_sales_report汇总表:每个 SKU 当天下单件数、支付件数、取消件数、销售额。报表查询只读汇总表,返回很快。实时聚合虽然也能做,但管理员看报表的时候对数据实时性要求没那么高,定时汇总的性价比高得多。
5. 部署编排与踩坑记录:Docker Compose 把六服务跑起来
5.1 容器化与启动顺序
每个服务打成镜像的方式基本一致,区别只在启动命令不同。Dockerfile 核心内容:
FROM python:3.10-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 5000 CMD ["gunicorn", "app:app", "-b", "0.0.0.0:5000", "-w", "3", "--timeout", "60"]docker-compose.yml 里需要特别处理的是依赖启动顺序。MySQL 和 Redis 要先起来,各业务服务再起来,网关最后起来,否则网关启动时会因为后端服务不可用而报错。Compose 里加了 healthcheck:
services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 5s retries: 10 user-service: build: ./services/user-service depends_on: mysql: condition: service_healthy redis: condition: service_healthy启动整套系统只需要一条命令,这个体验对开发环境来说足够了。生产环境如果服务数量继续增长,或者需要自动伸缩,再考虑迁移到容器编排平台,那也是后面的事。
5.2 配置管理:环境变量注入,不写死在代码里
六个服务都要访问 MySQL、Redis、JWT 密钥等公共配置,如果代码里写死,部署环境一换就要改代码重新构建。我用了一个根目录下的.env文件,Compose 启动时自动注入:
MYSQL_HOST=mysql MYSQL_PORT=3306 REDIS_HOST=redis REDIS_PORT=6379 JWT_SECRET=your-secret-key INTERNAL_TOKEN=internal-token每个服务内部用os.getenv("JWT_SECRET")读取,不把它们写进代码仓库。这里特别提醒:.env文件要加入.gitignore,尤其是 JWT_SECRET 和 INTERNAL_TOKEN 这类敏感信息。
5.3 三个印象最深的线上坑
踩坑一:SQLAlchemy 连接池导致"一小时后第一请求必报错"。这个坑太经典了。MySQL 服务端默认wait_timeout是 8 小时,但有些云数据库会配置成更短的超时,连接长时间空闲后池里的连接实际上已经断了,下一次请求拿到的还是一个已失效连接,报错MySQL server has gone away。解决办法是在 SQLAlchemy 的 engine 配置里加pool_pre_ping=True,每次从连接池取连接时先 ping 一下,失效就重连。
踩坑二:上传图片时网关 502。Nginx 默认的proxy_read_timeout是 60 秒,当时管理后台批量导入商品图片,每个请求处理时间超过 60 秒,Nginx 会把连接断开,前端收到 502。排查时看 Nginx 错误日志是upstream timed out,定位很快。解决方式是区分场景:普通接口保持短超时,图片处理接口单独设置更长的 proxy_read_timeout。
踩坑三:Redis 缓存穿透导致数据库被打爆。商品详情页做了缓存,但如果有人恶意循环请求根本不存在的商品 ID,缓存里没有,数据库里也没有,每个请求都会打到 MySQL。最简单的缓解是把空结果也缓存起来,加一个很短的过期时间,比如 60 秒。更好的方案是布隆过滤器,但因为系统已经有 Redis,直接用空值缓存成本更低,实测效果已经足够。
5.4 压测结果与调优方向
整体流程跑通后,我用压测工具对网关层做了简单压测。本地笔记本 8 核环境下,300 并发跑了 2 万次请求,核心接口(登录、商品详情、下单)平均 QPS 大约在 900 左右,p95 响应时间约 350ms。瓶颈基本都集中在 api-gateway 的 CPU 上,因为网关同时在做 token 校验、路由转发、请求头处理。
优化方向主要有三个:一是给网关增加副本数量,用 Nginx 做负载均衡;二是把 token 校验里的密文解析换成更轻量的本地缓存,校验过一次的 token 在短时间内直接放行;三是把一些不要求实时性的操作改成异步落库,比如访问日志、操作记录。这些优化做完后,网关 QPS 能再往上涨不少,但我觉得对一个管理系统的日常流量来说,900 QPS 已经绰绰有余了,真正要紧的是保证高峰期的稳定性而不是极限数字。
6. 最后说几句体己话
项目做完后再回头看,微服务并不是银弹。如果团队只有两三个人、系统日活不高、业务逻辑也不算复杂,老老实实用单体 Flask 反而更舒服。但当你已经明显感觉到模块耦合拖慢了发布速度,或者某个子模块的流量需求和其他模块差异很大时,拆分就是一个正确的选择。
我给自己的一个判断标准是:先让单体活下来,再谈拆分。这套商城系统之所以能平稳迁移,是因为原项目虽然耦合,但功能逻辑是完整的,测试用例也算齐,拆分时每一层都有据可循。如果一开始就是烂摊子,直接拆微服务只会把烂摊子拆成六个烂摊子。
后面如果继续演进,我大概率会先把重构过程中的一些手写组件替换成更通用的方案:Redis Stream 可以换成 RabbitMQ 或 Kafka,服务间 RPC 可以改成 gRPC,容器编排可以上平台,网关层的鉴权和限流也可以考虑用更成熟的网关方案。但这些都是锦上添花,核心的业务边界只要切对了,后面的演进都会顺。这套 Flask 微服务商城,真正值钱的地方不在技术栈选得多新,而在于每个服务都知道自己该管什么、不该管什么。