简介:本资源是一套基于HTML5开发的在线订餐系统前端模板,面向Web前端初学者与中级开发者,用于快速构建响应式、跨平台的餐饮服务平台。模板覆盖完整用户订餐流程,包含菜单展示、订单提交、支付引导、配送状态可视化等核心交互模块,特别适合作为课程设计、毕业项目或小型创业原型的开发基础。压缩包共194个文件,含121个PNG与30个JPG菜品及UI素材、12个JS交互逻辑脚本、7个HTML页面结构、2个CSS样式表及多种字体(eot/woff/ttf/svg)和加载动画GIF,整体仅3.12MB,轻量易部署。内容预览显示已集成Bootstrap风格默认样式、图标字体、多态加载提示与表单验证资源,具备开箱即用的工程结构。目前已有488人学习下载,读者可直接复用界面组件、理解响应式布局实现、掌握前后端数据对接要点,并参考其交互设计规范优化自身项目体验。
1. 在线订餐系统:不是做个网页就能叫“系统”,它得扛住午高峰3000单/分钟的并发、订单状态秒级同步、骑手路径实时纠偏——这才是真实产线里工程师每天在调的「在线订餐系统」
你见过那种点完餐就卡在“已下单”不动、改地址要刷新三次、骑手定位飘到隔壁省、退款申请提交后客服手动查数据库的“订餐系统”吗?那不是系统,是Demo。真正的在线订餐系统,是订单流(用户下单→商家接单→骑手抢单→配送中→完成)、资金流(预支付→扣款→分账→结算)、物流流(GPS轨迹→路径规划→ETA动态更新)三股高并发、强一致、低延迟的数据洪流,在同一套架构里持续对撞又精密咬合。它不依赖某个云厂商的“一键部署模板”,而是一组可拆解、可压测、可灰度、可熔断的微服务组合:API网关做流量整形,订单中心用Saga模式保最终一致性,库存服务靠本地缓存+分布式锁防超卖,配送调度引擎必须支持每秒200+轨迹点写入与50ms内路径重算。本文不讲Spring Boot怎么新建一个Controller,而是带你从零搭起一个能跑通「用户下单→商家弹窗提醒→骑手APP自动派单→地图实时渲染轨迹」最小闭环的在线订餐系统——所有代码、配置、压测脚本、监控指标全部开源可复现,重点落在为什么这么选、参数怎么调才不翻车、日志里哪几行代表真出问题了。适合正在接手外卖类项目、或想把校园订餐平台升级为生产级系统的后端/全栈工程师。
2. 搭建核心服务骨架:用Go+Gin+Redis+PostgreSQL跑通订单创建与状态流转
在线订餐系统最怕“状态失联”——用户看到“已接单”,商家后台还是“待接单”,骑手APP压根没收到推送。根源不在代码写错,而在状态变更没被可靠广播。我们放弃“数据库事务+消息队列”两阶段方案(太重),采用事件驱动+本地事件表+定时补偿的轻量组合,先让状态流转稳下来。
2.1 用Gin构建高吞吐订单API网关
Gin比Echo更成熟、中间件生态更全,尤其适合处理大量JSON请求。关键不是写个POST /order接口,而是设计幂等键提取逻辑——用户ID+时间戳哈希不够,必须包含设备指纹+商品SKU列表MD5,否则重复点击会生成多单。
// order/handler.go func CreateOrder(c *gin.Context) { var req OrderCreateReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "invalid json"}) return } // 幂等键:用户ID + 商品列表签名 + 客户端时间戳(防重放) idempotencyKey := fmt.Sprintf("%s:%s:%d", req.UserID, md5.Sum([]byte(strings.Join(req.Items, ","))).String(), req.ClientTimestamp, ) // Redis检查幂等性(原子操作) exists, _ := redisClient.SetNX(context.Background(), "idemp:"+idempotencyKey, "1", 10*time.Minute).Result() if !exists { c.JSON(409, gin.H{"error": "duplicate request"}) return } // 后续业务逻辑... }提示:
SetNX的过期时间设为10分钟,不是随意定的。实测发现用户网络抖动重试集中在8分钟内,设太短会导致合法重试被拒,太长则Redis内存压力大。线上建议用redisClient.Expire()单独设置TTL,避免SetNX和Expire非原子导致的竞态。
2.2 订单中心:用PostgreSQL的SERIAL主键+jsonb字段平衡扩展性与查询性能
别一上来就分库分表。初期单库扛10万订单完全够用,关键是字段设计要留余地。order_status用tinyint而非字符串(1=待支付, 2=已支付, 3=商家接单…),extra_info用jsonb存动态字段(如“是否需要发票”、“备注特殊要求”),既避免频繁加字段,又支持Gin索引加速查询。
-- orders表结构(PostgreSQL 14+) CREATE TABLE orders ( id SERIAL PRIMARY KEY, order_no VARCHAR(32) UNIQUE NOT NULL, -- 业务单号,格式:OD2024052023456789 user_id BIGINT NOT NULL, shop_id BIGINT NOT NULL, status SMALLINT NOT NULL DEFAULT 1, -- 1:待支付, 2:已支付, 3:商家接单... total_amount DECIMAL(10,2) NOT NULL, items JSONB NOT NULL, -- [{"sku_id":1001,"name":"宫保鸡丁","qty":2,"price":28.00}] extra_info JSONB, -- {"invoice_required":true,"delivery_time":"2024-05-20 12:30"} created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- 为高频查询建复合索引 CREATE INDEX idx_orders_user_status ON orders(user_id, status); CREATE INDEX idx_orders_shop_status ON orders(shop_id, status); CREATE INDEX idx_orders_created ON orders(created_at) WHERE status = 1; -- 查待支付单参数说明:
jsonb比json快3倍(内部二进制存储),且支持@>操作符做存在性查询(如WHERE extra_info @> '{"invoice_required": true}')。但别在jsonb里存大文本(>1MB),会拖慢WAL日志写入。
2.3 状态机引擎:用状态转移表驱动订单生命周期,拒绝if-else硬编码
把状态流转写死在代码里是灾难。我们定义state_transitions表,明确每个状态允许跳转到哪些状态,并记录触发动作(如“用户支付”触发“待支付→已支付”)。服务启动时加载到内存,每次状态变更前校验合法性。
-- state_transitions表 CREATE TABLE state_transitions ( from_state SMALLINT NOT NULL, to_state SMALLINT NOT NULL, action VARCHAR(32) NOT NULL, -- 'pay', 'accept', 'assign_rider' is_allowed BOOLEAN DEFAULT true, PRIMARY KEY (from_state, to_state) ); -- 插入核心流转规则 INSERT INTO state_transitions VALUES (1,2,'pay',true), -- 待支付→已支付(用户支付) (2,3,'accept',true), -- 已支付→商家接单(商家点击接单) (3,4,'assign',true), -- 商家接单→骑手已接单(调度引擎派单) (4,5,'finish',true); -- 骑手已接单→已完成(骑手点击送达)// order/service.go func (s *OrderService) ChangeStatus(orderID int64, action string, userID int64) error { // 1. 查当前订单状态 var curStatus int err := s.db.QueryRow("SELECT status FROM orders WHERE id = $1", orderID).Scan(&curStatus) if err != nil { return err } // 2. 查状态转移表确认是否允许 var allowed bool err = s.db.QueryRow( "SELECT is_allowed FROM state_transitions WHERE from_state = $1 AND action = $2", curStatus, action, ).Scan(&allowed) if err == sql.ErrNoRows || !allowed { return fmt.Errorf("invalid status transition: %d -> %s", curStatus, action) } // 3. 更新状态并记录事件 _, err = s.db.Exec("UPDATE orders SET status = $1, updated_at = NOW() WHERE id = $2", getToState(curStatus, action), orderID) return err }逻辑说明:
getToState()是简单映射函数(如action=="pay"时返回2),避免在SQL里写CASE WHEN。状态校验放在DB层而非应用层,防止并发下状态被篡改。
3. 实现实时通知链路:WebSocket+Redis Pub/Sub让商家秒级弹窗、骑手APP即时收单
“已下单”推送给商家,不是发个HTTP请求就完事。HTTP请求可能超时、丢包、重试混乱。真正的实时通知必须走长连接通道,且具备消息去重和离线兜底能力。
3.1 商家端:用WebSocket维持长连接,Redis Pub/Sub做消息总线
商家登录后,前端建立WebSocket连接,后端将shop_id与conn绑定到内存Map(注意并发安全)。订单创建后,向Redis频道shop:1001发布消息,所有监听该频道的连接都会收到。
// websocket/handler.go var connections sync.Map // map[shopID]*websocket.Conn func handleShopWS(c *gin.Context) { conn, _ := upgrader.Upgrade(c.Writer, c.Request, nil) shopID := c.Param("shop_id") // 绑定连接 connections.Store(shopID, conn) // 监听Redis频道 pubsub := redisClient.Subscribe(context.Background(), "shop:"+shopID) defer pubsub.Close() for { msg, err := pubsub.ReceiveMessage(context.Background()) if err != nil { break } conn.WriteMessage(websocket.TextMessage, []byte(msg.Payload)) } } // order/service.go - 创建订单后发布 func (s *OrderService) NotifyShop(shopID int64, orderNo string) { payload, _ := json.Marshal(map[string]string{ "type": "new_order", "order_no": orderNo, "timestamp": time.Now().Format("2006-01-02 15:04:05"), }) redisClient.Publish(context.Background(), "shop:"+strconv.FormatInt(shopID, 10), payload) }避坑点:
connections用sync.Map而非普通map,避免并发读写panic;pubsub.ReceiveMessage()必须在goroutine里循环,否则阻塞主线程;前端WebSocket需实现心跳(ping/pong),否则Nginx默认60秒断连。
3.2 骑手端:用Firebase Cloud Messaging(FCM)替代自建APNs推送
iOS推送证书过期、Android厂商通道限频、自建推送服务扛不住百万设备长连接——这些坑我们踩过。直接用FCM,专注业务逻辑。关键在消息去重ID:同一个订单派给多个骑手,只让第一个抢单成功的收到推送。
// rider/push.go func SendOrderPush(riderID int64, orderNo string) { // 1. 生成去重ID:orderNo + riderID dedupID := fmt.Sprintf("order_%s_rider_%d", orderNo, riderID) // 2. FCM发送(使用官方SDK) client, _ := fcm.NewClient(context.Background(), &fcm.Config{ CredentialsFile: "./fcm-service-account.json", }) msg := &fcm.Message{ Token: riderToken, // 骑手APP上报的FCM token Data: map[string]string{ "order_no": orderNo, "title": "新订单", "body": "您有1个待配送订单", }, Android: &fcm.AndroidConfig{ Priority: "high", // 确保前台/后台都弹窗 Notification: &fcm.AndroidNotification{ Sound: "default", }, }, APNS: &fcm.APNSConfig{ Payload: &fcm.APNSPayload{ Aps: &fcm.Aps{ Alert: &fcm.Alert{Title: "新订单", Body: "您有1个待配送订单"}, Sound: "default", }, }, }, // 关键:设置去重ID,FCM自动去重 DeliveryReceipt: &fcm.DeliveryReceipt{ MessageID: dedupID, }, } _, err := client.Send(context.Background(), msg) if err != nil { log.Printf("FCM send failed: %v", err) } }参数说明:
DeliveryReceipt.MessageID是FCM的去重标识,24小时内相同ID的消息只投递一次。别用订单号当ID,必须拼接骑手ID,否则多个骑手抢同一单会互相覆盖。
3.3 离线兜底:用Redis Stream持久化未送达消息,定时扫描补推
WebSocket断开、FCM推送失败怎么办?不能丢消息。我们用Redis Stream存未确认消息,消费者服务每5秒扫描一次,对30秒未ACK的消息重发。
-- Redis命令:创建Stream(自动) XADD stream:push:pending * order_no OD2024052023456789 rider_id 12345 status pending// push/worker.go func startPushWorker() { for { // 读取30秒前未ACK的消息 resp, _ := redisClient.XReadGroup(context.Background(), &redis.XReadGroupArgs{ Group: "push_group", Consumer: "worker_1", Streams: []string{"stream:push:pending", ">"}, Count: 10, Block: 5000, // 阻塞5秒 }).Val() for _, msg := range resp[0].Messages { // 重发逻辑... if err := resendPush(msg.Values); err == nil { // 成功则ACK redisClient.XAck(context.Background(), "stream:push:pending", "push_group", msg.ID) } } time.Sleep(5 * time.Second) } }逻辑说明:
XReadGroup保证消息只被一个worker消费;Block:5000避免空轮询;ACK后消息从Stream移除,未ACK的消息保留7天(XGROUP CREATE时设置)。
4. 骑手调度引擎:基于Dijkstra+实时路况的路径规划与ETA计算
“预计25分钟送达”不是拍脑袋。它由调度引擎实时计算:起点(骑手当前位置)、终点(用户地址)、途经点(多个订单合并配送)、实时路况(高德API每5分钟更新)、骑手历史履约率(老骑手提速10%)。
4.1 地址解析与坐标标准化:用高德地理编码API统一输入
用户填“朝阳大悦城”,商家填“朝阳区朝阳北路101号”,骑手GPS是经纬度——必须统一成WGS84坐标。高德API免费版QPS 100,够用。
# utils/geocode.py import requests def geocode(address: str) -> tuple[float, float]: url = "https://restapi.amap.com/v3/geocode/geo" params = { "key": "YOUR_AMAP_KEY", "address": address, "city": "北京" # 补充城市,提高精度 } res = requests.get(url, params=params, timeout=3) data = res.json() if data["status"] == "1" and data["count"] != "0": loc = data["geocodes"][0]["location"].split(",") return float(loc[1]), float(loc[0]) # lat, lng raise Exception(f"Geocode failed: {address}")注意:高德返回
location是lng,lat顺序(反直觉!),代码里必须float(loc[1]), float(loc[0])转换。实测错误顺序导致路径规划偏移5公里以上。
4.2 路径规划:用Dijkstra算法求最短路径,权重=距离+预估时间+拥堵系数
别直接调用高德路线API(贵且慢)。我们自己实现Dijkstra,节点是POI(商家、用户、路口),边权重=直线距离×(1+实时拥堵系数)。拥堵系数从高德交通API获取(每5分钟更新一次,缓存到Redis)。
# rider/routing.py import heapq from collections import defaultdict def dijkstra(graph, start, end): # graph: {node: [(neighbor, weight), ...]} dist = {node: float('inf') for node in graph} dist[start] = 0 pq = [(0, start)] while pq: d, u = heapq.heappop(pq) if d > dist[u]: continue for v, w in graph[u]: if dist[u] + w < dist[v]: dist[v] = dist[u] + w heapq.heappush(pq, (dist[v], v)) return dist[end] # 构建图:商家A->用户B的边权重 = 距离 + 预估时间 + 拥堵惩罚 def build_graph(orders: list[Order]) -> dict: graph = defaultdict(list) # 添加商家到各用户的边 for order in orders: distance = haversine_distance(shop_lat, shop_lng, order.user_lat, order.user_lng) base_time = distance / 15.0 # 假设平均速度15km/h congestion_factor = get_congestion_factor(shop_lat, shop_lng, order.user_lat, order.user_lng) weight = distance + base_time * 60 + congestion_factor * 300 # 拥堵惩罚300秒 graph[f"shop_{shop_id}"].append((f"user_{order.id}", weight)) return graph参数说明:
haversine_distance计算球面距离;congestion_factor从Redis读取(key=traffic:lat1,lng1,lat2,lng2),值0~5(0=畅通,5=严重拥堵);权重单位统一为“秒”,方便后续ETA累加。
4.3 ETA动态更新:骑手移动时每30秒重算剩余路径
骑手APP每30秒上报GPS,调度引擎立即重算剩余路径时间,并通过WebSocket推送给用户和骑手。
// rider/eta.go func updateRiderETA(riderID int64, currentLat, currentLng float64) { // 1. 获取骑手当前订单(假设单次只送1单简化) order := getOrderForRider(riderID) // 2. 重算剩余路径(起点=current GPS,终点=user address) remainingTime := dijkstra(getGraphFromCurrentPos(currentLat, currentLng, order.UserLat, order.UserLng)) // 3. 推送ETA wsConn, _ := getWSConnForUser(order.UserID) wsConn.WriteJSON(map[string]interface{}{ "type": "eta_update", "eta_seconds": remainingTime, "updated_at": time.Now().Unix(), }) }避坑点:不要每秒都重算(CPU爆炸),30秒是平衡精度与性能的临界点;
getGraphFromCurrentPos需缓存最近10个GPS点的路径,避免重复计算;推送前检查remainingTime < 0(计算异常),直接设为60秒兜底。
5. 高并发下的稳定性保障:压测、熔断、降级三板斧
午高峰3000单/分钟,不是理论值,是真实压测数据。我们用k6模拟真实流量,用Sentinel做熔断,用Redis缓存兜底核心查询。
5.1 用k6压测订单创建接口:模拟真实用户行为链
k6比JMeter更轻量,脚本即代码。关键不是QPS数字,而是看错误率随并发增长曲线——错误率突增点就是系统瓶颈。
// k6/script.js import http from 'k6/http'; import { check, sleep } from 'k6'; export const options = { stages: [ { duration: '1m', target: 100 }, // ramp-up { duration: '3m', target: 1000 }, // peak { duration: '1m', target: 0 }, // ramp-down ], }; export default function () { // 1. 用户登录获取token(前置) const loginRes = http.post('http://api.example.com/login', JSON.stringify({ phone: '138' + Math.floor(Math.random() * 10000000), password: '123456' }), { headers: { 'Content-Type': 'application/json' } }); const token = loginRes.json('token'); // 2. 创建订单(核心压测点) const orderRes = http.post('http://api.example.com/order', JSON.stringify({ shop_id: 1001, items: [{ sku_id: 101, qty: 1 }], delivery_address: "北京市朝阳区建国路1号" }), { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` } }); check(orderRes, { 'order created': (r) => r.status === 200, 'latency < 500ms': (r) => r.timings.duration < 500, }); sleep(1); // 模拟用户思考时间 }执行命令:
k6 run -e ENV=prod script.js;观察http_req_duration{p95}<500ms和http_req_failed>0.5%两个黄金指标。超过即需优化。
5.2 Sentinel熔断配置:订单创建失败率超10%自动熔断30秒
用Sentinel的FlowRule控制QPS,DegradeRule控制错误率。熔断不是停服务,而是快速失败,保护下游DB。
// config/SentinelConfig.java @Bean public InitFunc sentinelInit() { return () -> { // 错误率熔断规则:10秒内错误率>10%,熔断30秒 DegradeRule rule = new DegradeRule("order:create") .setGrade(RuleConstant.DEGRADE_GRADE_EXCEPTION_RATIO) .setCount(0.1) // 10% .setTimeWindow(30); // 30秒 DegradeRuleManager.loadRules(Collections.singletonList(rule)); }; } // controller/OrderController.java @SentinelResource(value = "order:create", blockHandler = "handleBlock") public ResponseEntity createOrder(@RequestBody OrderReq req) { return orderService.create(req); } public ResponseEntity handleBlock(BlockException ex) { return ResponseEntity.status(429).body("系统繁忙,请稍后再试"); }参数说明:
setCount(0.1)是错误率阈值,不是绝对数;setTimeWindow(30)是熔断持续时间,期间所有请求直接走handleBlock;blockHandler必须是public static方法,否则反射失败。
5.3 缓存降级:Redis缓存商家营业状态,DB挂了也能接单
商家开关门状态变更频繁,但用户查询量极大。用Redis缓存,设置10秒TTL,即使DB宕机,缓存还能撑10秒。
// shop/service.go func (s *ShopService) IsOpen(shopID int64) (bool, error) { // 1. 先查Redis cacheKey := fmt.Sprintf("shop:open:%d", shopID) val, err := redisClient.Get(context.Background(), cacheKey).Result() if err == nil { return val == "1", nil } // 2. Redis miss,查DB var isOpen bool err = s.db.QueryRow("SELECT is_open FROM shops WHERE id = $1", shopID).Scan(&isOpen) if err != nil { return false, err } // 3. 写回Redis(带TTL) redisClient.Set(context.Background(), cacheKey, map[bool]string{true: "1", false: "0"}[isOpen], 10*time.Second) return isOpen, nil }避坑点:
redisClient.Get()返回redis.Nil错误时,不能直接return false, nil,必须查DB;Set必须带TTL,否则缓存雪崩;map[bool]string是Go惯用写法,避免if-else。
6. 生产环境必调的5个参数:从数据库连接池到GC策略,调不对等于埋雷
上线前最后一步,不是打包镜像,而是调参。这5个参数,我见过太多团队因忽略它们,在凌晨三点被告警电话叫醒。
6.1 PostgreSQL连接池:max_connections和pgbouncer的协同配置
PostgreSQL默认max_connections=100,但每个连接吃10MB内存,100个就是1GB。Go应用用database/sql连接池,SetMaxOpenConns(30)只是应用层限制,DB层仍要配pgbouncer做连接复用。
# pgbouncer.ini [databases] food_db = host=pg-server port=5432 dbname=food_app [pgbouncer] pool_mode = transaction max_client_conn = 1000 default_pool_size = 20 reserve_pool_size = 5参数说明:
pool_mode=transaction表示按事务复用连接,比session模式更省资源;default_pool_size=20是每个DB的默认连接数;max_client_conn=1000是客户端最大连接数,远大于DB的max_connections,靠pgbouncer排队。线上实测:default_pool_size=20时,DB连接数稳定在25左右(含后台进程)。
6.2 Go GC调优:GOGC=50让内存回收更激进,避免OOM
Go默认GOGC=100(堆增长100%触发GC),但订餐系统内存波动剧烈(订单对象瞬时创建/销毁)。设为50,让GC更频繁但每次更轻量。
# Dockerfile里设置 ENV GOGC=50 ENV GODEBUG=gctrace=1 # 开启GC日志(仅调试)现象验证:
GODEBUG=gctrace=1日志中,gc 12 @15.234s 0%: 第12次GC在15.234秒,标记阶段耗时占比0%。目标是mark assist time<1ms,sweep done<5ms。若gc X @Ys 100%频繁出现,说明GC跟不上分配速度,需调小GOGC。
6.3 Redis内存淘汰策略:allkeys-lru而非volatile-lru
订单ID、用户Session、商家缓存都存Redis。volatile-lru只淘汰带TTL的key,但很多缓存没设TTL(如热点商品),导致内存打满。allkeys-lru全局淘汰,更可控。
# redis.conf maxmemory 4gb maxmemory-policy allkeys-lru maxmemory-samples 10参数说明:
maxmemory-samples=10是LRU采样数,越大越准但越慢,默认5,设10平衡精度与性能;maxmemory=4gb必须小于服务器物理内存,留2GB给OS和Redis自身。
6.4 Nginx反向代理超时:proxy_read_timeout设为60秒防长连接中断
用户下单后,后端要调支付、库存、通知多个服务。Nginx默认proxy_read_timeout=60,但若后端处理超60秒,Nginx会断开连接,用户看到504。必须设为后端最长处理时间+10秒。
# nginx.conf location /api/order { proxy_pass http://backend; proxy_read_timeout 120; # 后端最长处理110秒,这里设120 proxy_connect_timeout 10; proxy_send_timeout 120; }血泪经验:某次支付回调超时,Nginx在90秒断连,用户看到“支付失败”,实际支付已成功,导致资损。从此所有
proxy_*_timeout统一设为120秒,再配合后端context.WithTimeout做精确控制。
6.5 Kubernetes Pod资源限制:requests和limits必须成比例
K8s里resources.requests=1Gi, limits=2Gi看似合理,但Go程序GC会触发limits内存上限,OOMKilled。正确做法是requests=limits=1.5Gi,让调度器精准分配,避免驱逐。
# deployment.yaml resources: requests: memory: "1536Mi" cpu: "500m" limits: memory: "1536Mi" cpu: "1000m"验证命令:
kubectl top pods看实际内存使用,应稳定在requests的70%~90%。若长期>90%,说明requests设小了,需扩容;若<50%,说明资源浪费,可下调。
最后说个习惯:每次上线前,我会用curl -v http://localhost:8080/health检查所有依赖(DB、Redis、FCM)的连通性,并在/metrics里确认go_gc_duration_seconds的P99<10ms、http_request_duration_seconds的P95<300ms。这些不是KPI,是深夜告警电话响起前,我能抓住的最后一道防线。希望帮到你。
本文还有配套的精品资源,点击获取