Shopify 数据工程师面试实录 2026:多租户数据隔离 + GraphQL 数据层 完整复盘
Shopify Data Engineer 面试真实经历:多租户数据隔离设计、GraphQL vs REST 数据层架构、商家分析管道、实时转换漏斗完整复盘。第一人称面经,含面试官对话与解题思路。
公司:Shopify 岗位:Data Engineer (L4) 面试形式:Phone Screen + Virtual Onsite (4 轮) 结果:Pass → Offer
2026 年 2 月通过内推投递了 Shopify 的 Data Engineer 岗位。整个流程大约 4 周。
Shopify 的 DE 面试最独特的地方是:多租户数据隔离贯穿每一轮。 作为一个电商平台,Shopify 要管理数百万商家的数据,每个商家的数据必须严格隔离。面试中多次考察”如何在共享基础设施上保证租户间的数据隔离”这个问题。
Phone Screen:多租户 SQL 查询
题目:计算每个商家的收入(带租户隔离)
给定订单表,计算每个商家在过去 30 天的总收入、订单数和平均客单价。
-- 订单表
-- orders: order_id, merchant_id, created_at, total_price, currency, status
-- 我的解答
SELECT
o.merchant_id,
COUNT(*) AS order_count,
ROUND(SUM(o.total_price), 2) AS total_revenue,
ROUND(AVG(o.total_price), 2) AS avg_order_value,
COUNT(DISTINCT o.customer_id) AS unique_customers
FROM orders o
WHERE o.created_at >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
AND o.status = 'paid'
GROUP BY o.merchant_id;
面试官追问:
“如果有 500 万商家,这个查询的聚合结果怎么安全地暴露给每个商家?”
我回答:
-- 方案 1: 物化视图 + Row-Level Security
CREATE MATERIALIZED VIEW merchant_revenue_summary AS
SELECT
merchant_id,
DATE(created_at) AS stat_date,
COUNT(*) AS order_count,
ROUND(SUM(total_price), 2) AS total_revenue
FROM orders
WHERE status = 'paid'
GROUP BY merchant_id, DATE(created_at);
-- 刷新策略:每小时刷新一次
REFRESH MATERIALIZED VIEW CONCURRENTLY merchant_revenue_summary;
-- 商家查询(RLS 自动过滤)
SELECT * FROM merchant_revenue_summary
WHERE merchant_id = current_setting('app.current_merchant_id');
-- 方案 2: Spark 分区写入
-- 按 merchant_id 分区,每个商家读自己的分区
spark.conf.set("spark.sql.shuffle.partitions", "2000")
revenue_df.write \
.partitionBy("merchant_id") \
.format("delta") \
.mode("overwrite") \
.save("s3://shopify-data/revenue_by_merchant/")
面试官继续问:
“GraphQL 和 REST API 在数据层设计上有什么区别?Shopify 为什么选择 GraphQL?”
我回答:
REST API 的问题是每个 endpoint 返回固定结构,前端要么拿到多余数据(over-fetching),要么需要多次请求(under-fetching)。GraphQL 让前端精确声明需要什么字段:
# 前端精确声明需要的数据
query MerchantDashboard($merchantId: ID!, $dateRange: DateRange!) {
merchant(id: $merchantId) {
name
revenue(dateRange: $dateRange) {
total
currency
breakdown {
date
amount
orderCount
}
}
topProducts(limit: 10) {
title
salesCount
revenue
}
customerMetrics {
newCustomers
repeatRate
}
}
}
数据层设计差异:
| 维度 | REST | GraphQL |
|---|---|---|
| 数据粒度 | 固定 endpoint | 客户端自定义 |
| 网络请求 | 多次(N+1 问题) | 一次 |
| 数据聚合 | 前端做 | 后端 resolver 聚合 |
| 缓存 | HTTP 缓存 | 需要自定义缓存层 |
Shopify 选择 GraphQL 的原因:前端团队可以独立迭代,不需要后端频繁发版。
VO Round 1:Spark 编码 — 商家分析管道
题目:构建商家转换漏斗分析管道
分析商家从 page_view → add_to_cart → checkout → purchase 的完整转换漏斗。
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, count, when, lag, row_number, avg
from pyspark.sql.window import Window
spark = SparkSession.builder \
.appName("Shopify_Funnel_Analysis") \
.getOrCreate()
# ====== 漏斗分析 ======
# 定义漏斗步骤
funnel_events = events_df \
.filter(col("event_type").isin(
"page_view", "add_to_cart", "checkout_start", "purchase"
)) \
.withColumn("step",
when(col("event_type") == "page_view", 1)
.when(col("event_type") == "add_to_cart", 2)
.when(col("event_type") == "checkout_start", 3)
.when(col("event_type") == "purchase", 4)
)
# 按商家 + 会话聚合
session_funnel = funnel_events \
.groupBy("merchant_id", "session_id") \
.agg(
F.max("step").alias("max_step_reached"),
F.collect_list("event_type").alias("events")
)
# 计算每步转化率
funnel_counts = funnel_events \
.groupBy("merchant_id", "step") \
.agg(countDistinct("session_id").alias("unique_sessions"))
# 计算步骤间流失率
funnel_window = Window.partitionBy("merchant_id").orderBy("step")
funnel_rates = funnel_counts \
.withColumn("prev_sessions",
lag("unique_sessions").over(funnel_window)
) \
.withColumn("conversion_rate",
col("unique_sessions") * 100.0 / col("prev_sessions")
) \
.filter(col("prev_sessions").isNotNull())
funnel_rates.show()
# +-----------+----+-----------------+--------------+
# |merchant_id|step|unique_sessions |conversion_rate|
# +-----------+----+-----------------+--------------+
# | 1001 | 2 | 15000 | 45.5 |
# | 1001 | 3 | 8000 | 53.3 |
# | 1001 | 4 | 4000 | 50.0 |
题目 2:客户生命周期价值(CLV)
-- RFM 模型计算 CLV
WITH rfm AS (
SELECT
merchant_id,
customer_id,
MAX(order_date) AS last_order_date,
DATEDIFF(CURRENT_DATE(), MAX(order_date)) AS recency_days,
COUNT(*) AS frequency,
SUM(total_price) AS monetary
FROM orders
WHERE status = 'paid'
GROUP BY merchant_id, customer_id
),
rfm_scored AS (
SELECT
*,
NTILE(5) OVER (PARTITION BY merchant_id ORDER BY recency_days DESC) AS r_score,
NTILE(5) OVER (PARTITION BY merchant_id ORDER BY frequency) AS f_score,
NTILE(5) OVER (PARTITION BY merchant_id ORDER BY monetary) AS m_score
FROM rfm
)
SELECT
merchant_id,
customer_id,
r_score * 2 + f_score + m_score AS clv_score,
monetary AS lifetime_value
FROM rfm_scored
ORDER BY merchant_id, clv_score DESC;
VO Round 2:系统设计 — 多租户数据平台
题目:设计 Shopify 的商家数据平台
我的架构设计:
┌──────────────────────────────────────────────────────────────┐
│ Shopify Data Platform (Multi-Tenant) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
│ │ Store A │ │ Store B │ │ Store C │ │ ... 5M+ │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬──────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ │ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Kafka (Tenant-Aware) │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ Topic: merchant_events │ │ │
│ │ │ Partition Key: merchant_id (确保同商家数据同分区) │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Spark Streaming (Enrichment) │ │
│ │ ┌────────────┐ ┌────────────┐ ┌──────────────────┐ │ │
│ │ │ Process │ │ Aggregate │ │ Write to Delta │ │ │
│ │ │ by Tenant │ │ by Tenant │ │ (tenant_isolated)│ │ │
│ │ └────────────┘ └────────────┘ └──────────────────┘ │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌───────────────────┼───────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Delta Lake │ │ Redshift │ │ GraphQL API │ │
│ │ (Raw Data) │ │ (Serving) │ │ (Dashboard) │ │
│ │ tenant_isolated│ │ tenant_isolated│ │ tenant_isolated│ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Data Retention & Deletion │ │
│ │ ┌────────────┐ ┌────────────┐ ┌──────────────────┐ │ │
│ │ │ Auto- │ │ GDPR/CCPA │ │ Tenant Delete │ │ │
│ │ │ Archive │ │ Compliance │ │ Cascade │ │ │
│ │ │ (90 days) │ │ (Right to │ │ (Store Closure) │ │ │
│ │ │ │ │ be │ │ │ │ │
│ │ │ │ │ forgotten)│ │ │ │ │
│ │ └────────────┘ └────────────┘ └──────────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
租户隔离的实现方式:
# 1. Kafka: merchant_id 作为 partition key
producer.send(
'merchant_events',
key=merchant_id.encode(), # 确保同商家数据在同一分区
value=event_json
)
# 2. Delta Lake: 按 merchant_id 分区
df.write \
.partitionBy("merchant_id", "date") \
.format("delta") \
.mode("append") \
.save("s3://shopify-data/events/")
# 3. 查询时自动过滤(Row-Level Security)
def get_merchant_data(merchant_id: str) -> DataFrame:
"""商家只能读取自己的数据"""
return spark.read \
.format("delta") \
.load("s3://shopify-data/events/") \
.filter(col("merchant_id") == merchant_id)
# 4. 数据保留策略
from pyspark.sql.functions import col
# 自动归档 90 天前的数据
old_data = spark.read \
.format("delta") \
.load("s3://shopify-data/events/") \
.filter(col("event_date") < F.current_date() - F.lit(90))
old_data.write \
.mode("append") \
.save("s3://shopify-archive/events/")
# 删除已归档数据
spark.sql("""
DELETE FROM events
WHERE event_date < DATE_SUB(CURRENT_DATE(), 90)
""")
# 5. 商家关闭时的级联删除
def delete_merchant_data(merchant_id: str):
"""GDPR/CCPA 合规:商家关闭时删除所有数据"""
spark.sql(f"""
DELETE FROM orders WHERE merchant_id = '{merchant_id}'
""")
spark.sql(f"""
DELETE FROM customers WHERE merchant_id = '{merchant_id}'
""")
spark.sql(f"""
DELETE FROM events WHERE merchant_id = '{merchant_id}'
""")
面试官追问:
“如果商家数据量差异很大(有的商家每天 10 万订单,有的只有 10 单),怎么保证查询性能?”
我回答:
# 方案 1: 商家分级
# - 小商家(< 1000 orders/day):共享查询引擎
# - 中商家(1000-10000 orders/day):专用查询队列
# - 大商家(> 10000 orders/day):独立数据仓库实例
# 方案 2: 预聚合
# 对小商家,提前聚合好常用指标,查询时直接读聚合表
# 对大商家,保留原始数据 + 物化视图
# 方案 3: 缓存策略
# 热数据(最近 7 天)→ Redis
# 温数据(7-90 天)→ Redshift
# 冷数据(> 90 天)→ S3 + Athena(按需查询)
VO Round 3:行为面试
”讲一次你处理数据泄露或隐私事件的经历”
我分享了一个真实经历:之前公司的一个数据管道把用户的 PII(个人身份信息)没有脱敏就写到了分析数据库中。
处理过程:
- 发现后立即停止管道运行
- 评估影响范围——哪些数据被泄露、影响了多少用户
- 紧急清理——从分析库中删除 PII 数据
- 根本原因——schema 定义中没有
sensitive标记 - 长期修复——加了自动 PII 检测和脱敏 pipeline
”你怎么和产品团队在数据功能上有分歧?”
我分享了关于”是否要追踪用户点击热图”的分歧——产品团队想要尽可能多的数据,但我从数据成本和用户隐私角度建议只追踪关键事件。最终我们达成妥协:只追踪核心转化路径上的事件,并做了用户同意管理。
面试总结
成功经验
- 多租户思维:从电话面到 system design,每一轮都围绕”租户隔离”展开
- GraphQL 理解:Shopify 大量使用 GraphQL,了解其数据层设计是加分项
- 数据合规意识:GDPR/CCPA 合规的数据处理流程是面试中的高频话题
注意事项
- 商家规模差异:设计时要考虑从 10 单/天到 10 万单/天的商家
- 数据保留策略:知道怎么设计自动归档和删除机制
- 隐私第一:Shopify 非常重视用户隐私,面试中会考察你的隐私保护意识