微服务中共享库写入是一个死胡同

banq

大家都说“永远不要在微服务之间共享写操作数据库”(共享写不可以,共享读可以)。

但有时现实迫使你不得不这么做——遗留系统迁移、紧迫的期限或性能要求使得共享数据库成为必要。

问题不在于它是否理想(它并非理想),而在于当你别无选择时,如何安全地做到这一点。

共享数据库模式意味着多个微服务写操作同一个数据库实例。这就像多个室友共用一个厨房——虽然可以实现,但需要严格的规则和细致的协调。

Schema表结构 变更:团队协调的噩梦
问题:假设团队 A(订单服务)决定通过更改表结构来优化查询orders。他们添加了一个新列,重命名了另一个列,并删除了他们认为未使用的数据。与此同时,团队 B(客户服务)一直在悄悄地从同一张表中读取数据,并将其添加到他们的仪表板中。

真实场景:

-- 星期一: A 队做出此更改:

ALTER TABLE orders 
DROP COLUMN customer_notes,
ADD COLUMN internal_processing_id INT,
MODIFY COLUMN status ENUM('pending','processing','shipped','delivered');

-- 星期二: 客户服务中断
-- 他们的代码读取 customer_notes 并期望状态值为 "新"
-- 生产仪表板瘫痪
-- 客户支持无法查看订单历史
-- 需要紧急回滚

级联效应:

  • 一个团队的“简单”优化破坏了其他三项服务
  • 紧急会议协调修复
  • 回滚需要与所有团队重新协调
  • 未来的变更现在需要五个不同团队的批准
  • 开发速度停滞

共享数据库 = 共享业务逻辑
隐性耦合:当团队共享数据库时,他们不可避免地也会开始共享业务逻辑。最初只是“读取一些数据”,最终却演变成复杂的依赖关系。

示例演变:

-- Week 1: 简单读

SELECT * FROM orders WHERE customer_id = ?

-- Week 3: 加入业务逻辑

SELECT * FROM orders 
WHERE customer_id = ? 
  AND status NOT IN ('cancelled', 'refunded')

-- Week 6: 复杂业务规则

SELECT o.*, c.tier_level,
  CASE 
    WHEN c.tier_level = 'premium' THEN o.amount * 0.95
    WHEN o.created_date > DATE_SUB(NOW(), INTERVAL 30 DAY) THEN o.amount * 0.98
    ELSE o.amount
  END as effective_amount
FROM orders o 
JOIN customers c ON o.customer_id = c.id
WHERE o.customer_id = ?
  AND o.status NOT IN ('cancelled', 'refunded')
  AND (o.payment_status = 'completed' OR c.tier_level = 'premium')

问题:现在客户服务系统已经嵌入了订单服务的业务逻辑。如果订单服务更改其定价规则,客户服务系统就会崩溃。这样一来,你就创建了一个伪装成微服务的分布式单体应用。

数据损坏:当服务相互干扰时
无协调的并发更新:多个服务在彼此不知情的情况下更新相同的数据会造成竞争条件和数据损坏。

真实灾难场景:

  • 时间 10:00:01 - 订单服务读取客户余额:100美元
  • 时间 10:00:02 - 支付服务读取客户余额:100美元
  • 时间 10:00:03 - 订单服务扣除30美元(用于购买)
  • 时间 10:00:04 - 订单服务写入余额:70美元
  • 时间 10:00:05 - 支付服务处理20美元退款
  • 时间 10:00:06 - 支付服务写入余额:120美元(100 + 20)

结果:客户完成了一笔30美元的购买和一笔20美元的退款,
但余额显示为120美元而非正确的90美元,
由于竞态条件导致50美元差额丢失!


库存噩梦:

商品库存:剩余 1 件
客户 A 的请求(订单服务):

  • 读取库存:1
  • 验证:可满足订单
  • 库存减至 0
  • 创建订单

客户 B 的请求(快速订单服务):
  • 读取库存:1(在订单服务写入前读取)
  • 验证:可满足订单
  • 库存减至 -1
  • 创建订单

结果:实际仅 1 件库存却售出了 2 件订单        客户投诉、订单履约混乱


意外数据删除:午夜惊魂
分析团队的错误:

  • -- 分析团队以为自己在清理测试数据

DELETE FROM orders WHERE created_date < '2024-01-01';
  • -- 但他们不知道的是:
  • -- 订单服务使用的是软删除(status = 'deleted')
  • -- 客户服务依赖这些数据生成历史报表
  • -- 支付服务需要这些数据计算税务
  • -- 三年的生产数据就这么消失了

数据库迁移出错:

  • -- DevOps 团队正在迁移客户数据

UPDATE customers SET region = 'US' WHERE region IS NULL;
  • -- 但他们没有意识到:
  • -- 订单服务用 NULL 地区标识国际客户
  • -- 定价服务对 NULL 地区有特殊逻辑
  • -- 导致数千客户的税费计算错误
  • -- 在多国引发法律合规问题

(结果:国际订单定价混乱、税务违规、紧急回滚引发服务中断)


数据库滥用模式:问题出在哪里
“便捷”的直接查询
如何开始:

产品经理:"能不能在仪表盘加上客户终身价值指标?"
开发者:"没问题,我直接从订单表查就行..."

-- 6个月后

SELECT
c.customer_id,
c.email,
SUM(o.total) as lifetime_value,
COUNT(o.id) as order_count,
AVG(p.profit_margin) as avg_margin,
MAX(o.created_date) as last_order
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
LEFT JOIN products p ON o.product_id = p.id
WHERE c.created_date > DATE_SUB(NOW(), INTERVAL 1 YEAR)
GROUP BY c.customer_id
HAVING COUNT(o.id) > 5
ORDER BY lifetime_value DESC
LIMIT 1000;

(实际后果:
1️⃣ 生产数据库CPU飙升至100%
2️⃣ 关键交易查询超时失败
3️⃣ 客服系统因连表查询崩溃
4️⃣ 财务发现利润计算与CRM数据不一致
5️⃣ 最后发现这个看板没人用)

经典教训:
• 报表查询不该直连OLTP数据库
• 缺少数据仓库层导致资源挤占
• 业务逻辑应该封装在服务层
• 这个"简单需求"暴露了架构债


问题:这个看似简单的查询现在却拥有了复杂的业务逻辑,而这些逻辑本应属于订单服务和产品服务。当这些服务更改其数据模型时,这个查询就会中断。

“紧急”数据修复
深夜灾难:

凌晨3点:"生产环境紧急故障!订单显示价格错误!"
程序员:"我直接在数据库里修复一下"

UPDATE orders
SET total = subtotal + tax + shipping
WHERE created_date = CURDATE()
AND total != (subtotal + tax + shipping);

-- 问题在于:
-- 不知道订单服务有复杂的折扣计算逻辑
-- 没有更新审计追踪记录
-- 破坏了财务对账系统
-- 财务部几周后才发现这个烂摊子

(最终后果:
人工修复了2000个订单但改错了300个
审计时发现数据篡改痕迹引发合规风险
因折扣计算错误导致公司损失$15,000
事后被迫编写长达50页的事故报告

血泪教训:

  • 永远不要绕过服务层直接修改生产数据
  • 价格这类核心业务逻辑必须保持单一数据源
  • 紧急修复往往会造成更大的技术债务


功能逐渐蔓延
第 1 个月:客户服务读取基本订单数据第 3 个月:添加一些过滤和排序功能
第 6 个月:为客户支持实现订单状态更新第 9 个月:添加订单修改功能第 12 个月:基本重复了订单服务功能

结果:您现在有两个都管理订单的服务,但没有明确的所有权并且存在很多冲突。

️ 安全实施:真正有效的规则
规则 1:严格的数据所有权
写入规则:
表权限矩阵说明
订单表(orders)权限分配:

  • 订单服务:读写权限(OWNER)
  • 客户服务:只读权限
  • 分析服务:只读权限
  • 其他服务:禁止直接访问

客户表(customers)权限分配:
  • 客户服务:读写权限(OWNER)
  • 订单服务:只读权限
  • 分析服务:只读权限
  • 其他服务:禁止直接访问

执行:

-- 订单服务账户配置
CREATE USER 'order_service'@'%' IDENTIFIED BY 'secure_password_123!';
GRANT SELECT, INSERT, UPDATE, DELETE ON ecommerce.orders TO 'order_service'@'%';
GRANT SELECT ON ecommerce.customers TO 'order_service'@'%';

-- 客户服务账户配置
CREATE USER 'customer_service'@'%' IDENTIFIED BY 'secure_password_456@'; 
GRANT SELECT ON ecommerce.orders TO 'customer_service'@'%';
GRANT SELECT, INSERT, UPDATE, DELETE ON ecommerce.customers TO 'customer_service'@'%';

-- 分析服务账户配置(只读所有表)
CREATE USER 'analytics_service'@'%' IDENTIFIED BY 'secure_password_789#';
GRANT SELECT ON ecommerce.* TO 'analytics_service'@'%';

-- 密码策略增强
ALTER USER 'order_service'@'%' PASSWORD EXPIRE INTERVAL 90 DAY;
ALTER USER 'customer_service'@'%' PASSWORD EXPIRE INTERVAL 90 DAY;
ALTER USER 'analytics_service'@'%' PASSWORD EXPIRE INTERVAL 90 DAY;

最佳实践补充建议

  1. 网络层隔离:考虑添加IP限制(@'specific_ip'替代@'%')
  2. 审计跟踪:启用数据库审计日志
  3. 密钥管理:使用Vault等系统管理密码而非硬编码
  4. 权限定期审查:每季度检查一次权限分配
  5. 服务账户隔离:不同环境(dev/staging/prod)使用不同账户

此设计确保了:
  • 每个服务只能访问必要的数据
  • 遵循了最小权限原则
  • 明确了数据所有权边界
  • 为审计和合规打下基础

规则 2:表结构模式变更协议
三阶段迁移:
第一阶段:仅增量变更(第1周)
变更内容:

  • 添加带有默认值的新列(避免NOT NULL约束)
  • 创建新索引(不影响现有查询性能)
  • 添加新表(与现有表无外键关系)

沟通措施:
  • 提前2周通知所有依赖团队变更计划
  • 在共享频道发布变更时间窗口
  • 提供初步的ER图和API文档草案

-- 添加新列(带有默认值)
ALTER TABLE orders ADD COLUMN estimated_delivery_days INT DEFAULT 3;

-- 创建新索引(非阻塞式)
CREATE INDEX idx_orders_region ON orders(region) ALGORITHM=INPLACE, LOCK=NONE;

第二阶段:应用代码更新(第2-3周)
迁移策略:

  • 采用双写模式(新旧字段同时更新)
  • 实现条件读取逻辑(优先读新字段,fallback到旧字段)
  • 在CI/CD流水线中加入新schema的兼容性测试

监控重点:
  • 新旧字段数据一致性(每日校验作业)
  • 性能指标对比(P99延迟、吞吐量)
  • 错误日志中的字段弃用警告

⚠️ 回滚准备:
  • 保留旧字段处理代码
  • 准备快速回滚的热修复分支
  • 数据库备份点每天增加一次

第三阶段:旧模式清理(第4周)
清理条件:

  • 所有服务至少发布两个稳定版本使用新schema
  • 监控显示零流量访问旧字段(持续7天)
  • 获得各团队负责人的书面确认

️ 清理操作:

-- 分批次执行(每个变更单独事务)
BEGIN;
ALTER TABLE orders DROP COLUMN legacy_shipping_code;
COMMIT;

-- 在低峰期执行
SET SESSION lock_wait_timeout = 300;
DROP INDEX idx_orders_legacy ON orders;

应急方案:

  1. 立即停止清理流程
  2. 从备份恢复被删除的列(预先准备的恢复脚本)
  3. 重新启用旧版应用代码路径
  4. 召开事故复盘会议

迁移检查清单
✅ 生产环境数据库备份验证
✅ 所有相关服务负责人确认
✅ 监控仪表板添加新指标跟踪
✅ 文档团队更新数据字典
✅ 安排变更后48小时特别值班

规则 3:数据完整性保护
使用数据库约束:(注意,这些约束如果涉及太详细写入数据的业务约束,最好使用DDD实现,因为数据表耦合太多业务约束,万一这些约束变更,需要更改表结构,引入更大的隐患,最好业务写入规则在统一的地方统一修改,而不是在代码和数据库两个地方分开实现。)

数据完整性约束实现
库存防负约束
-- 确保库存量永远不会为负数

ALTER TABLE products ADD CONSTRAINT chk_positive_stock
CHECK (stock_quantity >= 0) NOT ENFORCED; -- 先设置为不强制执行

-- 应用层过渡期后改为强制执行

ALTER TABLE products ALTER CONSTRAINT chk_positive_stock ENFORCED;

订单关联约束
-- 防止孤儿订单(确保customer_id始终有效)

ALTER TABLE orders ADD CONSTRAINT fk_order_customer
FOREIGN KEY (customer_id) REFERENCES customers(id)
ON DELETE RESTRICT -- 禁止删除有订单的客户
DEFERRABLE INITIALLY DEFERRED; -- 允许事务结束时验证

状态机约束
-- 创建状态变更验证函数

CREATE FUNCTION validate_order_status_change(
    old_status VARCHAR(20),
    new_status VARCHAR(20)
) RETURNS BOOLEAN AS $$
BEGIN
    RETURN (
        (old_status = 'pending' AND new_status IN ('processing', 'cancelled')) OR
        (old_status = 'processing' AND new_status IN ('shipped', 'cancelled')) OR
        (old_status = 'shipped' AND new_status = 'delivered') OR
        (old_status = ANY(ARRAY['delivered', 'cancelled']) AND new_status = old_status)
    );
END;
$$ LANGUAGE plpgsql;

-- 应用状态机约束

ALTER TABLE orders ADD CONSTRAINT chk_valid_status_transition
CHECK (validate_order_status_change(
    (SELECT status FROM orders WHERE id = OLD.id),
    NEW.status
));

并发控制实现
库存更新事务

-- 使用行级锁的库存扣减事务
BEGIN;
-- 显式锁定要修改的行(避免丢失更新)
SELECT stock_quantity FROM products 
WHERE id = ? AND stock_quantity >= ? 
FOR UPDATE NOWAIT; -- 如果无法立即获取锁则失败

-- 检查库存是否充足
IF NOT FOUND THEN
    ROLLBACK;
    RAISE EXCEPTION 'Insufficient stock or product not found';
END IF;

-- 执行库存扣减
UPDATE products SET stock_quantity = stock_quantity - ? 
WHERE id = ?;

-- 记录库存变更流水
INSERT INTO inventory_transactions 
(product_id, quantity_change, remaining_stock)
VALUES (?, -?, (SELECT stock_quantity FROM products WHERE id = ?));

COMMIT;

优化建议
锁超时设置:

SET LOCAL lock_timeout = '2s'; -- 每个事务设置合理的锁等待超时

批处理优化:

-- 批量更新时使用SKIP LOCKED跳过被锁定的行

SELECT * FROM products 
WHERE category_id = ?
FOR UPDATE SKIP LOCKED
LIMIT 100;

乐观并发控制:

-- 使用版本号实现乐观锁

UPDATE products 
SET stock_quantity = stock_quantity - ?,
    version = version + 1
WHERE id = ? AND version = ?;

监控死锁:
-- 配置死锁日志记录

ALTER SYSTEM SET deadlock_timeout = '1s';


此方案提供了:

  • 强数据完整性保证
  • 安全的并发控制机制
  • 灵活的状态管理
  • 可扩展的锁策略
  • 完善的监控能力


规则 4:审计一切
数据库审计跟踪:

CREATE TABLE data_audit_log (
  id INT AUTO_INCREMENT PRIMARY KEY,
  table_name VARCHAR(50),
  operation VARCHAR(10),
  row_id INT,
  old_values JSON,
  new_values JSON,
  changed_by VARCHAR(50),
  service_name VARCHAR(50),
  timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Trigger example for orders table
DELIMITER $$
CREATE TRIGGER orders_audit_update 
AFTER UPDATE ON orders FOR EACH ROW
BEGIN
  INSERT INTO data_audit_log (
    table_name, operation, row_id, old_values, new_values, 
    changed_by, service_name
  ) VALUES (
    'orders', 'UPDATE', NEW.id,
    JSON_OBJECT('status', OLD.status, 'total', OLD.total),
    JSON_OBJECT('status', NEW.status, 'total', NEW.total),
    USER(), 'detected_via_trigger'
  );
END$$
DELIMITER ;

监控:在问题爆发之前发现它们
模式表结构变更检测
监控数据库结构:

-- Daily check for schema changes
SELECT 
  TABLE_NAME,
  COLUMN_NAME,
  DATA_TYPE,
  IS_NULLABLE,
  COLUMN_DEFAULT
FROM INFORMATION_SCHEMA.COLUMNS 
WHERE TABLE_SCHEMA = 'your_database'
ORDER BY TABLE_NAME, ORDINAL_POSITION;

-- Alert if schema changed without going through approval process

跨服务数据访问监控
跟踪哪些服务访问了哪些内容:

-- Log all queries with service identification
SELECT 
  DATE(timestamp) as query_date,
  service_name,
  table_accessed,
  operation_type,
  COUNT(*) as query_count
FROM query_audit_log 
WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY DATE(timestamp), service_name, table_accessed, operation_type
ORDER BY query_count DESC;

可疑模式警报:

  •  
  • 服务访问不该拥有的表
  • 跨服务数据访问量突然激增
  • 从应为只读的服务进行写入操作
  • 工作时间内架构发生变更

数据一致性检查✅
自动完整性验证:

-- Check for orphaned records
SELECT COUNT(*) as orphaned_orders
FROM orders o 
LEFT JOIN customers c ON o.customer_id = c.id 
WHERE c.id IS NULL;

-- Check for negative inventory
SELECT COUNT(*) as negative_stock_products
FROM products 
WHERE stock_quantity < 0;

-- Check for invalid order totals
SELECT COUNT(*) as invalid_order_totals
FROM orders 
WHERE total != (subtotal + tax + shipping);

危机管理:当事情出错时
数据损坏响应计划
立即行动:

止血- 确定哪个服务导致了损坏
隔离损坏- 将受影响的表置于只读模式
评估范围——有多少数据受到影响?
快速沟通——向所有团队通报该问题
恢复策略:

-- Example: Fixing inventory corruption
-- Step 1: Put products table in read-only mode
FLUSH TABLES products WITH READ LOCK;

-- Step 2: Calculate correct inventory from order history
CREATE TEMPORARY TABLE correct_inventory AS
SELECT 
  p.id,
  p.initial_stock - COALESCE(SUM(oi.quantity), 0) as correct_stock
FROM products p
LEFT JOIN order_items oi ON p.id = oi.product_id
LEFT JOIN orders o ON oi.order_id = o.id  
WHERE o.status IN ('shipped', 'delivered')
GROUP BY p.id;

-- Step 3: Update with correct values
UPDATE products p
JOIN correct_inventory ci ON p.id = ci.id
SET p.stock_quantity = ci.correct_stock;

-- Step 4: Remove read-only lock
UNLOCK TABLES;

架构变更回滚↩️
安全回滚程序:

-- Always test rollback before making changes
-- Example: Rolling back a column addition

-- Original change:
-- ALTER TABLE orders ADD COLUMN priority INT DEFAULT 1;

-- Rollback plan:
BEGIN;
-- Check if any service is using the new column
SELECT COUNT(*) FROM orders WHERE priority != 1;
-- If count > 0, coordinate with teams before rollback

-- Safe rollback (only if no data in new column)
ALTER TABLE orders DROP COLUMN priority;
COMMIT;

退出策略:规划你的逃生之路
第一阶段:API 层介绍
构建服务 API:

Instead of direct database access:
Customer Service → GET /api/orders/by-customer/123

Order Service exposes:
GET /api/orders/{id}
GET /api/orders/by-customer/{customerId}  
POST /api/orders
PUT /api/orders/{id}/status

第二阶段:逐步迁移
替换直接 DB 调用:

Week 1: 10% of reads go through APIs
Week 4: 50% of reads go through APIs
Week 8: 90% of reads go through APIs  
Week 12: 100% reads through APIs, remove direct DB access

阶段 3:数据分离
将数据移动到服务拥有的数据库:

-- Create separate databases
CREATE DATABASE customer_service_db;
CREATE DATABASE order_service_db;

-- Migrate data with consistency checks
-- Use database replication for zero-downtime migration

关键要点
共享数据库风险重重:每个共享访问点都可能存在故障。风险真实存在,甚至可能造成灾难性的后果。

严格的规则是不可协商的:如果没有明确的所有权和变更协议,几个月内就会陷入混乱。

监控一切:你无法管理你看不到的东西。全面的监控至关重要。

计划您的退出:共享数据库应该是一个临时桥梁,而不是永久的目的地。

做好灾难准备:数据损坏、意外删除和架构冲突等情况时有发生。请准备好应对计划。

沟通是关键:大多数共享数据库灾难源于团队之间沟通不畅。

共享数据库模式可以发挥作用,但它需要纪律、监控和持续的警惕。这就像做手术——必要时可以,但始终存在风险,需要专家护理。

banq注:共享数据库产生问题需要通过引入业务 DDD设计实现总体设计控制,以上是亡羊补牢的严格预防一切的方法,但是技术防范越严格,安全性提高了,人们无法方便修改了,谁愿意干活呢?