简介这是一套面向 Android 初学者及课程设计、毕业设计场景的同城物流派送项目资料以 SQLite 数据存储为主线串联用户注册登录、订单与司机信息管理、自定义适配器列表展示、抢单派送状态流转以及 GPS 定位与地图路线规划等环节可用于理解城市配送应用从数据表设计到界面交互的完整实现。压缩包共 275 个文件、约 53.42MB包含 31 个 Java 源码、39 个 XML 布局与配置、86 个 class 文件、94 张 png 界面素材以及 jar、so 依赖和可安装 apk便于对照源码与运行效果。已有 323 人学习读者可从中获取完整的 Android 工程结构、SQLite 增删改查与账号校验思路、自定义适配器绑定方法、订单状态同步流程和定位导航的实践参考。资料还保留了较清晰的分层目录与资源素材适合用来拆解模块、查漏补缺或作为同类同城配送项目的基础模板进行二次学习。1. 同城物流派送这套东西到底解决了什么先看清一个门店派单台的日常去年帮一个做本地生鲜的门店改配送他们最初的派单方式很原始店长在群里喊一声三个骑手自己抢谁先回消息谁去送。日均单量过了两百之后丢单、重复派、客户催单找不到人这些破事全冒出来了。这套同城物流派送项目就是照着这个场景写的门店下单、系统就近派单、骑手接单跑腿、后台看轨迹和时效一条链路打通。它跟常见的配送系统最大的区别是底层没有用 MySQL而是 sqlite —— 一个文件就是整个库拷走dispatch.db业务就跟着走门店换台电脑不用找人导数据。合适的读者很明确日均几百单的小型同城配送团队、想在本地先跑通调度模型再考虑上云的开发者以及拿它练手地理检索和状态机的人。下面按「能不能用、跑不跑得起来、怎么改」的顺序往下拆。2. 技术选型与目录结构sqlite 撑起派单链路的边界在哪2.1 为什么敢用 sqlite写入量、并发模型与它的天花板先说结论这套系统把 sqlite 放在服务端本地磁盘上跑而不是嵌在客户端里属于「单机服务 单文件数据库」的形态。敢这么选是因为同城派送的真实写入曲线并不陡一个日均 300 单的门店订单表一天新增 300 行骑手轨迹按 5 秒一个点、3 个骑手在线 10 小时算也不过两万行出头。这种量级对 sqlite 来说毫无压力单条 INSERT 在普通机械盘上都能跑到几千 TPS。真正的限制不在写入量而在并发模型。sqlite 的写锁是库级别的同一时刻只允许一个写事务读可以并发WAL 模式下读不阻塞写。所以架构上必须立两条规矩第一所有事务短到毫秒级不允许在事务里做 HTTP 调用、算距离、发短信这类慢操作第二写操作集中到一个连接池里的少量连接读走另外的连接。项目里我用threading.local给每个请求线程持有独立连接避免多线程共享 connection 导致的SQLite objects created in a thread can only be used in that same thread。这条报错几乎是所有 sqlite 项目的第一课血泪经验。它的天花板也要说清楚如果单城市日均单量过万、骑手过百写锁争抢会开始明显database is locked会零星冒出来。这时候的迁移成本很低——表结构基本不用动换个驱动换套连接池就能上 MySQL 或 PostgreSQL。所以选 sqlite 不是赌它永远够用是赌「先跑通业务模型比先选对数据库更值钱」。2.2 目录结构与模块职责一层只干一件事项目是 Python Flask 的组织方式没有上 ORMSQL 全部手写在 dao 层。这么做的理由很实际派单要算距离、要按索引顺序取前 N 条ORM 生成的 SQL 经常跟你想的不一样出问题时看执行计划还得反推。手写 SQL 虽然啰嗦但每条语句都在自己眼皮底下。dispatch/ ├── app.py # Flask 入口注册蓝图、启动时初始化 PRAGMA ├── config.py # 数据库路径、派单半径、计费参数 ├── db/ │ ├── conn.py # 线程局部连接、PRAGMA 设置、事务上下文 │ ├── schema.sql # 建表与索引 │ └── migrate.py # 版本化迁移记录 schema_version ├── dao/ │ ├── order_dao.py # 订单读写 │ ├── rider_dao.py # 骑手状态与位置 │ └── track_dao.py # 轨迹批量写入与查询 ├── service/ │ ├── order_service.py # 下单、状态流转 │ ├── dispatch_service.py# 派单算法 │ └── fee_service.py # 计费 ├── api/ │ ├── order_api.py # /api/order/* │ └── rider_api.py # /api/rider/* └── tests/ └── test_dispatch.py # 用内存库跑派单用例分层的边界卡得很死api 层只做参数校验和响应封装service 层管事务和业务规则dao 层只认 SQL 参数、不认业务语义。这么分之后改计费规则不会碰到 SQL换数据库不会碰到接口。2.3 从零启动依赖、初始化与第一条订单跑起来的步骤不多但每一步的顺序不能错尤其是 PRAGMA 必须在连接建立后立刻设晚了 WAL 模式不生效。# 1. 建虚拟环境装依赖Flask 即可其余走标准库 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install flask # 2. 初始化数据库执行 schema.sql 并写入演示骑手 python -m db.migrate --init # 3. 启动服务 python app.py # 默认 127.0.0.1:5000初始化完成后目录下会多出dispatch.db、dispatch.db-wal、dispatch.db-shm三个文件。后两个是 WAL 模式的副产物删库时要一起删只删主文件会导致下次启动读到不一致状态——这个坑我踩过一次排查了半小时。启动之后先打一条下单请求验证链路curl -X POST http://127.0.0.1:5000/api/order \ -H Content-Type: application/json \ -d { order_no: T20240601001, shop_id: 1, customer_tel: 13800000000, from: [116.404, 39.915], to: [116.418, 39.921], to_address: 某小区 3 号楼 502, weight_g: 3200 }参数里order_no由客户端生成服务端拿它做幂等from和to是[经度, 纬度]数组顺序别写反经度范围 ±180、纬度 ±90写反了距离会算出天文数字还不报错属于典型的静默翻车。weight_g用克做单位存整数避免浮点在计费里滚出 0.30000000000000004 这种数。3. 数据建模订单、骑手、轨迹三张主表加一张派单日志3.1 建表脚本字段语义与状态枚举四张表撑起整个业务。订单表是核心骑手表存实时位置和负载轨迹表存历史点位派单日志表负责可追溯和防重复派单。-- 订单表一行一单主键用业务单号而不是自增 id CREATE TABLE IF NOT EXISTS orders ( order_no TEXT PRIMARY KEY, -- 客户端生成的幂等键 shop_id INTEGER NOT NULL, customer_tel TEXT NOT NULL, from_lng REAL NOT NULL, from_lat REAL NOT NULL, to_lng REAL NOT NULL, to_lat REAL NOT NULL, to_address TEXT NOT NULL, weight_g INTEGER NOT NULL DEFAULT 0, fee_cent INTEGER NOT NULL DEFAULT 0, -- 费用以分存储规避浮点误差 status TEXT NOT NULL DEFAULT CREATED,-- CREATED/DISPATCHED/PICKED/ONWAY/DONE/CANCELED rider_id INTEGER, version INTEGER NOT NULL DEFAULT 0, -- 乐观锁版本号 created_at INTEGER NOT NULL, -- unix 秒不用文本时间 updated_at INTEGER NOT NULL ); -- 骑手表位置和负载都要能参与排序所以冗余在主表里 CREATE TABLE IF NOT EXISTS riders ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, phone TEXT NOT NULL, online INTEGER NOT NULL DEFAULT 0, -- 0 离线 1 在线 lng REAL, lat REAL, load_cnt INTEGER NOT NULL DEFAULT 0, -- 手上未完成单量 capacity INTEGER NOT NULL DEFAULT 3 -- 同时最多接几单 ); -- 轨迹表只写不改是典型的时间序列表 CREATE TABLE IF NOT EXISTS rider_track ( id INTEGER PRIMARY KEY AUTOINCREMENT, rider_id INTEGER NOT NULL, lng REAL NOT NULL, lat REAL NOT NULL, report_at INTEGER NOT NULL ); -- 派单日志唯一约束是防重复派的最后一道闸 CREATE TABLE IF NOT EXISTS dispatch_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT NOT NULL, rider_id INTEGER NOT NULL, result TEXT NOT NULL, -- ACCEPTED/REJECTED/TIMEOUT created_at INTEGER NOT NULL, UNIQUE(order_no, rider_id) );字段设计上有几个刻意的选择。费用用fee_cent存整数分不用REAL存元金融计算里浮点误差会累积到对账时才发现。时间统一存 unix 秒整数不存2024-06-01 12:00:00这种字符串——字符串排序在跨时区或者格式不统一时会出错而且整数比较比字符串比较快一个量级。status用文本枚举而不是数字好处是sqlite3命令行里一眼能看懂缺点是拼错不报错所以 service 层要有白名单校验。3.2 PRAGMA 与索引把列表页从 800ms 压到 30ms建完表只是开始sqlite 的默认配置是给小数据量场景设的服务端用必须调。项目在db/conn.py里每建一个连接就执行一遍import sqlite3 def new_conn(db_path: str) - sqlite3.Connection: conn sqlite3.connect(db_path, timeout5.0, isolation_levelNone) # WAL读写分离读不阻塞写是并发场景的必选项 conn.execute(PRAGMA journal_modeWAL) # NORMALWAL 下同步落盘频率降低掉电最多丢最后一个事务 conn.execute(PRAGMA synchronousNORMAL) # 写锁等待 5 秒再抛 database is locked别用默认的立即失败 conn.execute(PRAGMA busy_timeout5000) conn.execute(PRAGMA foreign_keysON) # 单页 4KB 提到了 8KB批量写轨迹时 IO 次数明显下降 conn.execute(PRAGMA page_size8192) conn.row_factory sqlite3.Row return connisolation_levelNone关掉了 Python 驱动的隐式事务改由代码显式BEGIN/COMMIT否则你会搞不清某条 UPDATE 到底提没提交。这个参数不改后面排查事务问题基本等于摸黑。索引按查询路径反推。后台列表页最常见的两个查询是「按状态取最新一页」和「某个骑手手上的单」所以-- 覆盖订单列表页的 status 时间倒序避免全表扫 临时排序 CREATE INDEX IF NOT EXISTS idx_orders_status_created ON orders(status, created_at DESC); -- 骑手维度查在途单 CREATE INDEX IF NOT EXISTS idx_orders_rider_status ON orders(rider_id, status); -- 轨迹按骑手 时间查配合主键自增做范围扫描 CREATE INDEX IF NOT EXISTS idx_track_rider_time ON rider_track(rider_id, report_at);验证方式不用猜直接看执行计划EXPLAIN QUERY PLAN SELECT order_no, status, created_at FROM orders WHERE status DISPATCHED ORDER BY created_at DESC LIMIT 20;输出里出现USING INDEX idx_orders_status_created才算走对如果看到USE TEMP B-TREE FOR ORDER BY说明索引没覆盖排序顺序和方向要调。未加索引时这个查询在 20 万行数据上跑到 800ms 左右加上之后稳定在 30ms 以内。3.3 状态机与事务边界哪些操作必须绑在一起订单状态是一条有向路径CREATED → DISPATCHED → PICKED → ONWAY → DONE任何一步都可以跳到CANCELED但DONE之后不允许再变。把它写成显式的状态迁移表比在代码里堆if status ...好维护ALLOWED { CREATED: {DISPATCHED, CANCELED}, DISPATCHED: {PICKED, CANCELED}, PICKED: {ONWAY, CANCELED}, ONWAY: {DONE, CANCELED}, DONE: set(), CANCELED: set(), } def check_transit(src: str, dst: str) - bool: return dst in ALLOWED.get(src, set())事务边界的原则只有一条凡是会破坏数据一致性的写必须在同一个事务里。派单要同时改订单状态和骑手负载这两个 UPDATE 必须绑在一起签收要改订单状态并累加骑手完成单量同样绑在一起。反过来发短信通知、推 WebSocket 这类外部副作用必须放在事务提交之后否则事务回滚了消息已经发出去用户收到「已派单」但库里没派。4. 核心链路走查从下单到签收的四个关键函数4.1 下单接口幂等键、地址校验与费用预计算下单是最容易被重复提交打穿的地方——骑手端网络抖一下重发、用户手快点两次都会造成一单变两单。处理方式是把order_no当幂等键先尝试插入冲突就直接返回已有订单不做任何副作用。from flask import Blueprint, request, jsonify import time from service.fee_service import calc_fee order_bp Blueprint(order, __name__) order_bp.post(/api/order) def create_order(): body request.get_json(forceTrue) # 必填校验单号是幂等键地址是履约前提缺一不可 for k in (order_no, shop_id, from, to, to_address): if not body.get(k): return jsonify(code400, msgfmissing field: {k}), 400 order_no body[order_no].strip() lng, lat float(body[from][0]), float(body[from][1]) to_lng, to_lat float(body[to][0]), float(body[to][1]) # 经纬度合法性经度 ±180、纬度 ±90写反了要当场拒掉 if not (-180 lng 180 and -90 lat 90 and -180 to_lng 180 and -90 to_lat 90): return jsonify(code400, msginvalid coordinate), 400 now int(time.time()) fee calc_fee(body.get(weight_g, 0), straight_meters(lng, lat, to_lng, to_lat)) with db_conn() as conn: # INSERT OR IGNORE 回查是 sqlite 上最省事的幂等写法 cur conn.execute( INSERT OR IGNORE INTO orders (order_no, shop_id, customer_tel, from_lng, from_lat, to_lng, to_lat, to_address, weight_g, fee_cent, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?, CREATED, ?, ?) , (order_no, body[shop_id], body.get(customer_tel, ), lng, lat, to_lng, to_lat, body[to_address], int(body.get(weight_g, 0)), fee, now, now)) if cur.rowcount 0: # 单号已存在直接返回原单绝不再触发计费或派单 row conn.execute( SELECT order_no, status, fee_cent FROM orders WHERE order_no?, (order_no,)).fetchone() return jsonify(code0, datadict(row), duplicatedTrue) return jsonify(code0, data{order_no: order_no, fee_cent: fee})关键点在INSERT OR IGNORE加rowcount判断这一段。很多人写幂等是先SELECT再INSERT中间有窗口期两个并发请求会同时查到「不存在」然后各自插入最后靠主键冲突报错兜底用户体验是接口 500。用「插入成功与否」作为唯一判据就不存在这个窗口。straight_meters是球面距离近似项目里没引第三方地理库用 haversine 二十行写完。注意返回值单位统一成米后面派单半径、计费阶梯都按米算别一会儿公里一会儿米混合单位是这类系统里最常见的静默 bug。4.2 派单矩形粗筛 距离精排 负载兜底派单算法不需要多复杂核心是两段式先用经纬度矩形把候选骑手砍到个位数再算精确距离排序。直接在 SQL 里对全表骑手算 haversine骑手数一多就退化成全表扫描。-- 粗筛以门店为中心画一个 ±0.01 度约 1.1km的矩形 SELECT r.id, r.lng, r.lat, r.load_cnt, -- 经度方向乘 cos(lat) 修正否则高纬度地区误差会很大 ((r.lng - :lng) * (r.lng - :lng) * :lng_k (r.lat - :lat) * (r.lat - :lat) * 111.0 * 111.0) AS dist2 FROM riders r WHERE r.online 1 AND r.load_cnt r.capacity AND r.lng BETWEEN :lng - :delta AND :lng :delta AND r.lat BETWEEN :lat - :delta AND :lat :delta ORDER BY dist2 ASC, r.load_cnt ASC LIMIT :limitdist2是距离的平方省掉开方排序结果和开方后一致纯属性能上的小便宜。:lng_k在 Python 侧算成(111.0 * math.cos(math.radians(lat))) ** 2传进去这里注意BETWEEN用到的delta要按目标半径换算成度数纬度方向 1 度约 111 公里经度方向还要乘cos(lat)。这套近似在几公里尺度内误差不到 1%对派单够用真要精确到米级就在粗筛结果上用 haversine 再算一遍。排序里的第二关键字load_cnt是负载兜底距离一样近的两个骑手优先派给手上单少的避免一个人跑断腿另一个人闲着。这套逻辑在骑手只有三五个的门店里看不出差别但扩展到几十个骑手时不均会导致超时率明显上升属于上线后才暴露的问题。4.3 接单与状态流转条件更新代替悲观锁骑手接单是典型的并发写场景一次派单可能同时推给多个骑手谁先点谁得。用悲观锁BEGIN EXCLUSIVE会让整个库的写入排队得不偿失。项目里用带条件的 UPDATE 做乐观并发控制靠rowcount判断是否抢到。def accept_order(conn, order_no: str, rider_id: int) - bool: now int(time.time()) # 条件里带上 status只有还在 DISPATCHED 的单才能被接 cur conn.execute( UPDATE orders SET status PICKED, rider_id ?, version version 1, updated_at ? WHERE order_no ? AND status DISPATCHED , (rider_id, now, order_no)) if cur.rowcount 0: return False # 已被别人接走或者状态不对直接返回失败 # 骑手负载 1和订单状态在同一事务里要么都成功要么都回滚 conn.execute( UPDATE riders SET load_cnt load_cnt 1 WHERE id ?, (rider_id,)) conn.execute( INSERT INTO dispatch_log (order_no, rider_id, result, created_at) VALUES (?, ?, ACCEPTED, ?) , (order_no, rider_id, now)) return True这段代码有三个细节值得说。第一WHERE里的status DISPATCHED就是乐观锁两个请求同时到sqlite 的写锁保证串行执行第一个把状态改走第二个的rowcount就是 0自然失败不需要额外查一次。第二负载累加和状态更新放在同一个事务靠调用方的with conn:保证如果中途异常整个回滚不会出现「单接了但负载没加」的脏数据。第三dispatch_log上有UNIQUE(order_no, rider_id)重复接单会直接抛约束冲突是最后一道防线。version字段在这套方案里其实不是必需的因为状态本身就是版本标记。留着它是为了给后续做「修改收货地址」这类需要额外版本判断的操作铺路属于预留。4.4 签收与轨迹回写批量写入和回调重试轨迹上报是写入量最大的接口骑手端 5 秒一个点如果每个点都走一次INSERT加一次事务提交sqlite 的 fsync 会成为瓶颈。项目里用攒批骑手端缓存 10 个点或 30 秒批量提交一次服务端用executemany一次写入。def flush_track(conn, rider_id: int, points: list): # points: [[lng, lat, ts], ...] if not points: return 0 # 只写时间不倒退的点防止骑手端时钟回拨把轨迹写乱 last conn.execute( SELECT MAX(report_at) FROM rider_track WHERE rider_id?, (rider_id,) ).fetchone()[0] or 0 rows [(rider_id, p[0], p[1], p[2]) for p in points if p[2] last] if not rows: return 0 conn.executemany( INSERT INTO rider_track (rider_id, lng, lat, report_at) VALUES (?,?,?,?), rows) # 同时更新骑手表上的最新位置供派单查询用避免每次都查轨迹表 conn.execute(UPDATE riders SET lng?, lat? WHERE id?, (rows[-1][1], rows[-1][2], rider_id)) return len(rows)签收接口除了改状态还要减骑手负载、算实际时长。时长不用另存字段updated_at - created_at现算就行但统计报表要按「天」聚合所以在DONE时把created_at落到一个日期字符串字段里更省事这属于典型的用空间换查询复杂度。签收后的通知回调不能放在事务里项目里是先提交再把订单丢进一个内存队列由后台线程重试失败三次落一张notify_retry表等人工处理。这套东西不优雅但比在事务里requests.post然后卡住整个写锁好一万倍。5. 避坑与排查database is locked、时间字段和轨迹膨胀5.1 并发写入的三个坑现象一压测时日志里零星出现sqlite3.OperationalError: database is locked低频但每次重启都复现。原因两个写事务撞上后到的那个在默认timeout内没等到锁。常见诱因是某个事务里夹了慢操作比如在事务内查了一次第三方地址解析接口把锁持有时间从 2ms 拉到了 800ms。解决三件事一起做——busy_timeout设到 5000ms 给个等待窗口isolation_levelNone手动管事务确保BEGIN和COMMIT之间没有任何 IO把外部调用全部挪到事务外面。改完之后压测 200 并发写锁冲突降到零。现象二多线程跑一段时间后报SQLite objects created in a thread can only be used in that same thread。原因图省事把 connection 做成了模块级单例Flask 的多线程 worker 共享同一个连接。解决用threading.local()每线程一个连接请求结束不关闭而是复用靠连接池上限控制总量。sqlite 的连接创建成本极低没必要做复杂池化。现象三只读查询偶尔也变慢明明没在写。原因WAL 模式下长时间不 checkpoint-wal文件涨到几百 MB每次读都要扫更大的索引。解决后台定时跑PRAGMA wal_checkpoint(TRUNCATE)放在低峰期比如每小时一次。同时监控-wal文件大小超过 100MB 就该告警了。5.2 时间与数值精度的坑现象按时间排序的列表页顺序错乱同一秒内的订单顺序随机跨月统计的金额对不上差几分钱。原因早期版本用datetime.now().strftime(%Y-%m-%d %H:%M:%S)存时间一是字符串比较依赖格式严格一致二是同一秒内无法区分先后金额用REAL存元累加时浮点误差累积。解决时间统一改成 unix 秒整数需要展示时在接口层格式化金额统一存分。老数据用一条迁移脚本刷过来UPDATE orders SET created_at strftime(%s, created_at)把文本转成秒。这条迁移要在事务外分批跑一次改几万行会长时间持锁。还有一个隐蔽的坑骑手端时钟不准。轨迹上报里时间戳由客户端给如果某台手机时间快了一小时这批点会插到未来之后的点全被report_at last过滤掉表现为该骑手轨迹断流。解决办法是服务端收到上报时校验时间戳与服务器时间偏差超过 5 分钟就丢弃并让客户端重新校准。5.3 轨迹表膨胀与派单重复现象跑三个月后后台列表页从 30ms 变成 400msdispatch.db涨到 4GB。原因轨迹表只写不删rider_track占了 95% 的体积索引跟着变大同一个库文件上的订单查询也被拖慢。解决按天做归档。每天凌晨把 7 天前的轨迹INSERT INTO rider_track_archive SELECT ...再DELETE然后VACUUM回收空间。注意VACUUM会重写整个库要独占必须放在没有写入的窗口用PRAGMA auto_vacuumINCREMENTAL配合PRAGMA incremental_vacuum(1000)分页回收更平滑适合不能停机的小系统。如果业务上只需要近几天的轨迹更彻底的做法是按月分表查询时按需拼表。现象同一个订单派给了同一个骑手两次dispatch_log里出现两条ACCEPTED。原因派单逻辑和接单逻辑不在同一事务服务重启时消息重投接单接口被调了两次第一次改了状态第二次因为状态已变返回失败但日志写入用的是单独连接先写进去了。解决日志写入并入同一事务并依赖UNIQUE(order_no, rider_id)兜底重复时用INSERT OR IGNORE静默跳过。另外在派单侧加一条约束同一订单 5 分钟内不接受第二次派单用一个内存里的dict做短期去重重启丢失也无所谓因为数据库层的唯一约束才是真正的后悔药。6. 二次开发从换计费规则开始把改动圈在一个模块里这套代码拿到手之后绝大多数人的第一件事是改计费。原版计费写死在fee_service.py里起步 6 元含 3 公里之后每公里 1.5 元超过 5 公斤加收。要改成阶梯计费或者按门店配置不同规则别去动order_service把计费抽成策略表就行。# fee_service.py RULES { 1: {base: 600, free_m: 3000, per_km: 150, free_g: 5000, per_kg: 100}, 2: {base: 800, free_m: 2000, per_km: 200, free_g: 3000, per_kg: 150}, } def calc_fee(shop_id: int, weight_g: int, dist_m: float) - int: r RULES.get(shop_id, RULES[1]) fee r[base] # 距离费超出免费里程的部分按公里向上取整避免出现 0.01 元这种尾差 if dist_m r[free_m]: fee math.ceil((dist_m - r[free_m]) / 1000) * r[per_km] # 重量费同理按公斤向上取整 if weight_g r[free_g]: fee math.ceil((weight_g - r[free_g]) / 1000) * r[per_kg] return fee参数全用「分」和「米」和「克」单位统一之后规则表怎么配都不会错。改完必须跑一遍验证用pytest拿内存库构造几组边界数据3 公里整、3.001 公里、5 公斤整、5.001 公斤断言金额等于手算值。计费这块我一般会强制要求有边界用例因为差一元钱的 bug 用户一定会发现而差一公里的 bug 往往要等到对账才暴露。第二类改造是多门店、多城市隔离。sqlite 有两种做法加city_id字段做逻辑隔离或者一城一库、启动时按参数打开。前者简单后者能天然分摊写锁。我倾向后者因为同城配送的业务本来就有地域边界跨城调数据的需求几乎为零一城一库还顺带解决了备份粒度问题——备份某个城市只要拷一个文件。最后是压测验证别用真实业务库跑。写个脚本灌 50 万订单、200 万轨迹点重点看三个指标订单列表页 P99 延迟、派单查询耗时、并发写入时的锁冲突次数。这三个数决定你能撑多大的单量也是决定什么时候该迁到 MySQL 的唯一依据。我自己的习惯是每次改完 dao 层的 SQL 都重跑一遍这个压测脚本哪怕只改了一个ORDER BY因为索引失效这种事从来不打招呼。从那以后我每次接 sqlite 项目第一件事就是把 PRAGMA 和压测脚本先落地而不是等业务跑起来再说。希望帮到你。本文还有配套的精品资源点击获取