2026年6月26日 星期五

製作公車查詢系統的資料庫

 

作者:楊先民  
精誠資訊/恆逸教育訓練中心資深講師


※網路引用請註明完整出處


本期是想到我在快20年前寫的一個行動裝置程式:公車查詢系統。

現在回想起來,當年不太懂要怎麼樣在行動裝置上才能更快,現在則沒有這種問題,因為現在的行動裝置已經不能和之前比了,速度上來說都非常快了,所以可以儘可能的用資料庫設計來解決使用者的需求。


公車查詢系統的使用者需求


公車查詢系統,主要還是查公車資訊,但是如果我們把需求再多增加,想要查詢兩站之間的轉車資訊,問題似乎就比較複雜了,總之,我們先把需求先講一下

設計公車查詢系統(像是台北等公車、公車動態動態查詢)時,資料庫的核心技術挑戰在於「如何最佳化高頻率的班次與路線查詢」以及「如何儲存複雜的站牌順序(去程/回程)」。

為了滿足使用者「查詢某路線公車還有幾分鐘到某站」、「查詢某站有哪些公車會到」等核心需求,我們通常會參考國際通用的 GTFS (General Transit Feed Specification) 標準來簡化設計。


以下規劃這套公車查詢系統的完整關聯式資料表設計如下:

為了好懂,我們把系統拆分為三個核心區塊:

🔵 藍色區域(路線與班次):管理公車路線(如:307、藍26)與發車班次。

🟢 綠色區域(站牌與地理資訊):管理實體站牌(如:台北車站、板橋公車站)的位置。

🟡 黃色區域(動態與預估時間):管理班次、路線、站牌之間的「順序關係」與「即時到站時間」。


資料表詳細規格規劃


表一:Routes (公車路線表)

記錄公車的基本資訊

欄位名稱 (Column Name)

資料型態

允許 NULL

說明

RouteID

INT IDENTITY(1,1)

主鍵 (PK)

RouteName

NVARCHAR(50)

路線名稱 (如:'307', '承德幹線')

DepartureStop

NVARCHAR(50)

起點站描述 (如:'撫遠街')

DestinationStop

NVARCHAR(50)

終點站描述 (如:'板橋')

Operator

NVARCHAR(50)

營運客運公司 (如:'首都客運')

IsActive

BIT

是否營運中 (1: 營運, 0: 停駛)

 

Stops (實體站牌表)

記錄每個實體站牌的名稱與經緯度

欄位名稱 (Column Name)

資料型態

允許 NULL

說明

StopID

INT IDENTITY(1,1)

主鍵 (PK)

StopName

NVARCHAR(100)

站牌名稱 (如:'南京敦化路口')

Latitude

DECIMAL(9,6)

緯度 (用於地圖定位)

Longitude

DECIMAL(9,6)

經度 (用於地圖定位)


RouteStops (路線站牌順序表)

同一條路線,去程和回程的站牌順序往往不同(甚至可能少停靠某幾站)。這張表用來定義「某一條路線的去/回程,第幾站是什麼站」。

欄位名稱 (Column Name)

資料型態

允許 NULL

說明

RouteStopID

INT IDENTITY(1,1)

主鍵 (PK)

RouteID

INT

外鍵 (FK),關聯 Routes

StopID

INT

外鍵 (FK),關聯 Stops

Direction

TINYINT

方向:0 = 去程, 1 = 回程

Sequence

INT

站序:從 1 開始遞增 (1站、第2...)

這張表必須加上 UNIQUE (RouteID, Direction, Sequence),確保一條路線的某個方向,絕對不會有兩個「第一站」。


Schedules (班次表 / 定時發車時間)

記錄這條路線每天的固定發車班次(例如首班車 05:30、每15分鐘一班)。

欄位名稱 (Column Name)

資料型態

允許 NULL

說明

ScheduleID

INT IDENTITY(1,1)

主鍵 (PK)

RouteID

INT

外鍵 (FK)

Direction

TINYINT

0 = 去程, 1 = 回程

DepartureTime

TIME(0)

總站發車時間 ( '06:15:00')

ServiceType

TINYINT

1: 平日, 2: 假日, 3: 天天行駛


-- 1. 路線表

CREATE TABLE Routes (

    RouteID INT IDENTITY(1,1) PRIMARY KEY,

    RouteName NVARCHAR(50) NOT NULL,

    DepartureStop NVARCHAR(50) NOT NULL,

    DestinationStop NVARCHAR(50) NOT NULL,

    Operator NVARCHAR(50) NULL,

    IsActive BIT NOT NULL DEFAULT 1

);

 

-- 2. 實體站牌表

CREATE TABLE Stops (

    StopID INT IDENTITY(1,1) PRIMARY KEY,

    StopName NVARCHAR(100) NOT NULL,

    Latitude DECIMAL(9,6) NOT NULL,

    Longitude DECIMAL(9,6) NOT NULL

);

 

-- 3. 路線站牌順序表

CREATE TABLE RouteStops (

    RouteStopID INT IDENTITY(1,1) PRIMARY KEY,

    RouteID INT NOT NULL FOREIGN KEY REFERENCES Routes(RouteID),

    StopID INT NOT NULL FOREIGN KEY REFERENCES Stops(StopID),

    Direction TINYINT NOT NULL, -- 0: 去程, 1: 回程

    Sequence INT NOT NULL,      -- 站牌順序

   

    -- 確保邏輯正確性:同路線同方向的站序不可重複

    CONSTRAINT UQ_Route_Direction_Seq UNIQUE (RouteID, Direction, Sequence)

);

 

-- 4. 班次表

CREATE TABLE Schedules (

    ScheduleID INT IDENTITY(1,1) PRIMARY KEY,

    RouteID INT NOT NULL FOREIGN KEY REFERENCES Routes(RouteID),

    Direction TINYINT NOT NULL, -- 0: 去程, 1: 回程

    DepartureTime TIME(0) NOT NULL,

    ServiceType TINYINT NOT NULL DEFAULT 3 -- 1:平日, 2:假日, 3:天天

);

 

-- 5. 效能優化索引(使用者點擊某一條公車路線時,查詢所有站牌速度要極快)

CREA COUNT(CASE WHEN PAResult IN ('1B', '2B', '3B', 'HR') THEN 1 END) * 1.0 /

    NULLIF(COUNT(CASE WHEN PAResult IN ('1B', '2B', '3B', 'HR', 'GO', 'FO', 'SO', 'E') THEN 1 END), 0) AS RISP_BA

FROM Plate_Appearances

TE NONCLUSTERED INDEX IX_RouteStops_Query ON RouteStops (RouteID, Direction) INCLUDE (StopID, Sequence);

 

關鍵功能 SQL 查詢範例

設計完表後,我們可以看看如何應對使用者在 App 上的即時查詢需求:

情境 A:使用者點選 307 公車「去程」,App 要依序顯示所有站名

SELECT rs.Sequence AS [站序], s.StopName AS [站牌名稱]

FROM RouteStops rs

JOIN Stops s ON rs.StopID = s.StopID

JOIN Routes r ON rs.RouteID = r.RouteID

WHERE r.RouteName = N'307' AND rs.Direction = 0

ORDER BY rs.Sequence ASC; -- 依站序從小到大排列


情境 B:使用者在「台北車站」等車,想知道有哪些公車路線會經過這一站?

SELECT DISTINCT r.RouteName AS [公車路線], r.DepartureStop + ' - ' + r.DestinationStop AS [行駛區間]

FROM RouteStops rs

JOIN Routes r ON rs.RouteID = r.RouteID

JOIN Stops s ON rs.StopID = s.StopID

WHERE s.StopName = N'台北車站' AND r.IsActive = 1;


那「還有幾分鐘到站」的即時資料呢?

在實務上,公車的「即時位置」和「預估到站時間(如:將到站、3分鐘)」是變動頻率極高的即時數據(通常每 5~15 秒更新一次)。

不建議:把每台公車這一秒的即時動態直接寫入傳統的關聯式資料庫(如 SQL Server 的實體表),這會導致硬碟 I/O 爆炸。

業界做法:通常會另外搭配 Redis(記憶體資料庫),將接收到的公車 GPS 資訊與動態預估時間,以快取(Cache)形式存在記憶體中。當前端 App 請求「307公車的即時動態」時,先從關聯式資料庫抓出 RouteStops 的靜態站牌順序,再將 Redis 裡的「即時到站秒數」組合在一起呈現給使用者,這樣系統的併發效能才會達到極致!


如果我要輸入站名資訊可以透過SQL指令找出如何轉車嗎?

在公車或捷運的查詢系統中,透過輸入「起點站」與「終點站」來找出「如何轉車」(路徑規劃),在電腦科學中屬於經典的圖形演算法(Graph Algorithm)問題。

如果想純粹透過 SQL 指令來達成這個功能,最核心的武器就是使用 SQL CTE 遞迴查詢(Recursive CTE。遞迴 CTE 能夠在資料庫內部不斷地自我調用、像探針一樣沿著公車路線一站一站往下搜尋,直到找到目的地為止。


以下示範如何設計這個防轉車的 SQL 查詢

建立搜尋的基礎:轉線檢視表(Data View

為了讓遞迴 SQL 寫起來更乾淨,我們需要先建立一個檢視表,把「哪一條公車路線,可以從 A 站直接坐到 B 站(同一方向、且 B 站序大於 A 站序)」的直達關係整理出來。


先執行以下 SQL 建立這個檢索依據:

CREA CREATE VIEW v_DirectConnections AS

SELECT

    r1.RouteID,

    r.RouteName,

    r1.Direction,

    r1.StopID AS FromStopID,

    s1.StopName AS FromStopName,

    r2.StopID AS ToStopID,

    s2.StopName AS ToStopName,

    (r2.Sequence - r1.Sequence) AS Distance -- 經過幾站

FROM RouteStops r1

-- 自我連接(Self-Join):找出同一條路線、同方向、後續的站

JOIN RouteStops r2 ON r1.RouteID = r2.RouteID

                  AND r1.Direction = r2.Direction

                  AND r2.Sequence > r1.Sequence

JOIN Routes r ON r1.RouteID = r.RouteID

JOIN Stops s1 ON r1.StopID = s1.StopID

JOIN Stops s2 ON r2.StopID = s2.StopID

WHERE r.IsActive = 1;


核心技術:遞迴 CTE 轉車搜尋 SQL

接下來,就是最關鍵的搜尋指令。我們設定起點為 '板橋公車站',終點為 '台北車站'。這個 SQL 會自動幫我們舉出:

1.       直達的公車有哪些

2.       轉車一次(換一次車)的搭乘組合有哪些

DECLAR DECLARE @StartStation NVARCHAR(100) = N'板橋公車站';

DECLARE @EndStation NVARCHAR(100) = N'台北車站';

 

WITH BusPaths AS (

    -- A. 錨點成員 Anchor】:找出所有從「起點站」出發的直達公車

    SELECT

        RouteName AS CurrentRoute,

        ToStopID,

        ToStopName,

        CAST(RouteName AS NVARCHAR(MAX)) AS TravelPath, -- 紀錄路線軌跡

        CAST(FromStopName + ' -> ' + ToStopName AS NVARCHAR(MAX)) AS StationPath, -- 紀錄站點軌跡

        0 AS TransferCount, -- 轉車次數

        Distance AS TotalStops

    FROM v_DirectConnections

    WHERE FromStopName = @StartStation

 

    UNION ALL

 

    -- B. 遞迴成員 Recursive】:上一班公車的「到站」,如果是另一班公車的「起點」,代表可以轉車

    SELECT

        nextBus.RouteName AS CurrentRoute,

        nextBus.ToStopID,

        nextBus.ToStopName,

        p.TravelPath + ' -> ' + nextBus.RouteName,

        p.StationPath + ' -> ' + nextBus.ToStopName,

        p.TransferCount + 1 AS TransferCount, -- 轉車次數 + 1

        p.TotalStops + nextBus.Distance

    FROM BusPaths p

    JOIN v_DirectConnections nextBus ON p.ToStopID = nextBus.FromStopID -- 轉車點交會

    WHERE p.TransferCount < 1 -- 🌟 限制最多只轉車 1 次(防止無限遞迴或繞遠路)

      AND p.TravelPath NOT LIKE '%' + nextBus.RouteName + '%' -- 防呆:不坐回重覆的路線

)

-- C. 最終篩選】:撈出所有成功到達「終點站」的路徑,並依轉車次數與站數排序

SELECT

    TransferCount AS [轉車次數],

    TravelPath AS [公車搭乘順序],

    StationPath AS [乘車路徑軌跡],

    TotalStops AS [總乘車站數]

FROM BusPaths

WHERE ToStopName = @EndStation

ORDER BY TransferCount ASC, TotalStops ASC;


查詢結果會長怎樣?

執行上述 SQL 後,資料庫會產生非常直觀的報表:

轉車次數

公車搭乘順序

乘車路徑軌跡

總乘車站數

0 (直達)

307

板橋公車站 -> 台北車站

12

0 (直達)

38

板橋公車站 -> 台北車站

15

1 (轉車)

26 -> 承德幹線

板橋公車站 -> 南京敦化路口(轉車點) -> 台北車站

18


20幾年前撰寫這個行動裝置的程式時,並沒有 CTE這樣的寫法,CTESQL Server 2005之後才有的語法,所以如果這個系統是交給前端來做的話,確實是比較快。

以上就是本期的主題設計公車查詢系統的資料庫。

0 意見:

張貼留言